From eb796d04284bba5133b48bb54c372f495b725ea9 Mon Sep 17 00:00:00 2001 From: Micah Geisel Date: Mon, 23 Dec 2024 14:51:22 -0600 Subject: [PATCH 1/7] failing test for returning false to beforeNodeMorphed during pantry restore. --- test/two-pass.js | 45 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/test/two-pass.js b/test/two-pass.js index aff806a..f702a4e 100644 --- a/test/two-pass.js +++ b/test/two-pass.js @@ -1,7 +1,5 @@ describe("Two-pass option for retaining more state", function () { - beforeEach(function () { - clearWorkArea(); - }); + setup(); it("fails to preserve all non-attribute element state with single-pass option", function () { getWorkArea().append( @@ -428,4 +426,45 @@ describe("Two-pass option for retaining more state", function () { ], ]); }); + + it("beforeNodeMorphed hook also applies to nodes restored from the pantry", function () { + getWorkArea().append( + make(` +
+

First paragraph

+

Second paragraph

+
+ `), + ); + document.getElementById("first").innerHTML = "First paragraph EDITED"; + document.getElementById("second").innerHTML = "Second paragraph EDITED"; + + let finalSrc = ` +
+

Second paragraph

+

First paragraph

+
+ `; + + Idiomorph.morph(getWorkArea(), finalSrc, { + morphStyle: "innerHTML", + twoPass: true, + callbacks: { + // basic implementation of a preserve-me attr + beforeNodePantried(node) { + if (node.parentNode?.dataset?.preserveMe) return false; + }, + beforeNodeMorphed(oldNode, newContent) { + if (oldNode.dataset?.preserveMe) return false; + }, + }, + }); + + getWorkArea().innerHTML.should.equal(` +
+

Second paragraph EDITED

+

First paragraph EDITED

+
+ `); + }); }); From 1341e04af4630fb545b80c411eb2d56859a288d3 Mon Sep 17 00:00:00 2001 From: Micah Geisel Date: Mon, 23 Dec 2024 14:56:17 -0600 Subject: [PATCH 2/7] we can just reuse morphNodeTo instead of this half-working mess. --- src/idiomorph.js | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/idiomorph.js b/src/idiomorph.js index b2e42a9..f4c8ff9 100644 --- a/src/idiomorph.js +++ b/src/idiomorph.js @@ -1249,22 +1249,10 @@ var Idiomorph = (function () { if (matchElement.parentElement?.moveBefore) { // @ts-ignore - use proposed moveBefore feature matchElement.parentElement.moveBefore(element, matchElement); - while (matchElement.hasChildNodes()) { - // @ts-ignore - use proposed moveBefore feature - element.moveBefore(matchElement.firstChild, null); - } } else { matchElement.before(element); - while (matchElement.firstChild) { - element.insertBefore(matchElement.firstChild, null); - } - } - if ( - ctx.callbacks.beforeNodeMorphed(element, matchElement) !== false - ) { - syncNodeFrom(matchElement, element, ctx); - ctx.callbacks.afterNodeMorphed(element, matchElement); } + morphOldNodeTo(element, matchElement, ctx); matchElement.remove(); } }); From 9058644457afe38528e43e1c924ec793e8f5726d Mon Sep 17 00:00:00 2001 From: Micah Geisel Date: Tue, 24 Dec 2024 15:51:59 -0600 Subject: [PATCH 3/7] remove abstraction-leaking beforeNodePantried hook and perform moves in first pass. --- src/idiomorph.js | 135 +++++++++++++++++++++------------------------- test/bootstrap.js | 38 ++++++++++--- test/two-pass.js | 36 ++++--------- 3 files changed, 103 insertions(+), 106 deletions(-) diff --git a/src/idiomorph.js b/src/idiomorph.js index f4c8ff9..97c17f3 100644 --- a/src/idiomorph.js +++ b/src/idiomorph.js @@ -20,7 +20,6 @@ * @property {function(Element): boolean} [beforeNodeRemoved] * @property {function(Element): void} [afterNodeRemoved] * @property {function(string, Element, "update" | "remove"): boolean} [beforeAttributeUpdated] - * @property {function(Element): boolean} [beforeNodePantried] */ /** @@ -61,7 +60,6 @@ * @property {(function(Node): boolean) | NoOp} beforeNodeRemoved * @property {(function(Node): void) | NoOp} afterNodeRemoved * @property {(function(string, Element, "update" | "remove"): boolean) | NoOp} beforeAttributeUpdated - * @property {(function(Node): boolean) | NoOp} beforeNodePantried */ /** @@ -133,7 +131,6 @@ var Idiomorph = (function () { beforeNodeRemoved: noOp, afterNodeRemoved: noOp, beforeAttributeUpdated: noOp, - beforeNodePantried: noOp, }, head: { style: "merge", @@ -208,7 +205,7 @@ var Idiomorph = (function () { // innerHTML, so we are only updating the children morphChildren(normalizedNewContent, oldNode, ctx); if (ctx.config.twoPass) { - restoreFromPantry(oldNode, ctx); + ctx.pantry.remove(); } return Array.from(oldNode.children); } else if (ctx.morphStyle === "outerHTML" || ctx.morphStyle == null) { @@ -233,7 +230,7 @@ var Idiomorph = (function () { nextSibling, ); if (ctx.config.twoPass) { - restoreFromPantry(morphedNode.parentNode, ctx); + ctx.pantry.remove(); } return elements; } @@ -365,12 +362,12 @@ var Idiomorph = (function () { // if we are at the end of the exiting parent's children, just append if (insertionPoint == null) { - // skip add callbacks when we're going to be restoring this from the pantry in the second pass if ( ctx.config.twoPass && ctx.persistentIds.has(/** @type {Element} */ (newChild).id) ) { - oldParent.appendChild(newChild); + const movedChild = moveBeforeById(oldParent, newChild.id, null, ctx); + morphOldNodeTo(movedChild, newChild, ctx); } else { if (ctx.callbacks.beforeNodeAdded(newChild) === false) continue; oldParent.appendChild(newChild); @@ -399,12 +396,26 @@ var Idiomorph = (function () { // if we found a potential match, remove the nodes until that point and morph if (idSetMatch) { - insertionPoint = removeNodesBetween(insertionPoint, idSetMatch, ctx); + if (ctx.config.twoPass) { + moveBefore(oldParent, idSetMatch, insertionPoint); + } else { + insertionPoint = removeNodesBetween(insertionPoint, idSetMatch, ctx); + } morphOldNodeTo(idSetMatch, newChild, ctx); removeIdsFromConsideration(ctx, newChild); continue; } + if ( + ctx.config.twoPass && // maybe we can run this in default mode too? + isSoftMatch(insertionPoint, newChild) + ) { + morphOldNodeTo(insertionPoint, newChild, ctx); + insertionPoint = insertionPoint.nextSibling; + removeIdsFromConsideration(ctx, newChild); + continue; + } + // no id set match found, so scan forward for a soft match for the current node let softMatch = findSoftMatch( newParent, @@ -416,7 +427,11 @@ var Idiomorph = (function () { // if we found a soft match for the current node, morph if (softMatch) { - insertionPoint = removeNodesBetween(insertionPoint, softMatch, ctx); + if (ctx.config.twoPass) { + moveBefore(oldParent, softMatch, insertionPoint); + } else { + insertionPoint = removeNodesBetween(insertionPoint, softMatch, ctx); + } morphOldNodeTo(softMatch, newChild, ctx); removeIdsFromConsideration(ctx, newChild); continue; @@ -425,16 +440,28 @@ var Idiomorph = (function () { // abandon all hope of morphing, just insert the new child before the insertion point // and move on - // skip add callbacks when we're going to be restoring this from the pantry in the second pass if ( ctx.config.twoPass && ctx.persistentIds.has(/** @type {Element} */ (newChild).id) ) { - oldParent.insertBefore(newChild, insertionPoint); + const movedChild = moveBeforeById( + oldParent, + newChild.id, + insertionPoint, + ctx, + ); + morphOldNodeTo(movedChild, newChild, ctx); } else { if (ctx.callbacks.beforeNodeAdded(newChild) === false) continue; - oldParent.insertBefore(newChild, insertionPoint); - ctx.callbacks.afterNodeAdded(newChild); + if (ctx.config.twoPass) { + // maybe this should be a bugfix and in both modes? + const newClonedChild = document.importNode(newChild, true); // clone as to not mutate newParent + oldParent.insertBefore(newClonedChild, insertionPoint); + ctx.callbacks.afterNodeAdded(newClonedChild); + } else { + oldParent.insertBefore(newChild, insertionPoint); + ctx.callbacks.afterNodeAdded(newChild); + } } removeIdsFromConsideration(ctx, newChild); } @@ -1184,48 +1211,18 @@ var Idiomorph = (function () { /** * - * @param {Node} tempNode + * @param {Node} node * @param {MorphContext} ctx */ - // TODO: The function handles tempNode as if it's Element but the function is called in - // places where tempNode may be just a Node, not an Element - function removeNode(tempNode, ctx) { - removeIdsFromConsideration(ctx, tempNode); - // skip remove callbacks when we're going to be restoring this from the pantry in the second pass + function removeNode(node, ctx) { + removeIdsFromConsideration(ctx, node); + // skip remove callbacks when we're going to be restoring this from the pantry later if ( ctx.config.twoPass && - hasPersistentIdNodes(ctx, tempNode) && - tempNode instanceof Element + hasPersistentIdNodes(ctx, node) && + node instanceof Element ) { - moveToPantry(tempNode, ctx); - } else { - if (ctx.callbacks.beforeNodeRemoved(tempNode) === false) return; - tempNode.parentNode?.removeChild(tempNode); - ctx.callbacks.afterNodeRemoved(tempNode); - } - } - - /** - * - * @param {Node} node - * @param {MorphContext} ctx - */ - function moveToPantry(node, ctx) { - if (ctx.callbacks.beforeNodePantried(node) === false) return; - - Array.from(node.childNodes).forEach((child) => { - moveToPantry(child, ctx); - }); - - // After processing children, process the current node - if (ctx.persistentIds.has(/** @type {Element} */ (node).id)) { - // @ts-ignore - use proposed moveBefore feature - if (ctx.pantry.moveBefore) { - // @ts-ignore - use proposed moveBefore feature - ctx.pantry.moveBefore(node, null); - } else { - ctx.pantry.insertBefore(node, null); - } + moveBefore(ctx.pantry, node); } else { if (ctx.callbacks.beforeNodeRemoved(node) === false) return; node.parentNode?.removeChild(node); @@ -1233,30 +1230,20 @@ var Idiomorph = (function () { } } - /** - * - * @param {Node | null} root - * @param {MorphContext} ctx - */ - function restoreFromPantry(root, ctx) { - if (root instanceof Element) { - Array.from(ctx.pantry.children) - .reverse() - .forEach((element) => { - const matchElement = root.querySelector(`#${element.id}`); - if (matchElement) { - // @ts-ignore - use proposed moveBefore feature - if (matchElement.parentElement?.moveBefore) { - // @ts-ignore - use proposed moveBefore feature - matchElement.parentElement.moveBefore(element, matchElement); - } else { - matchElement.before(element); - } - morphOldNodeTo(element, matchElement, ctx); - matchElement.remove(); - } - }); - ctx.pantry.remove(); + function moveBeforeById(parentNode, id, after, ctx) { + const target = + ctx.target.querySelector(`#${id}`) || ctx.pantry.querySelector(`#${id}`); + moveBefore(parentNode, target, after); + return target; + } + + function moveBefore(parentNode, element, after) { + // @ts-ignore - use proposed moveBefore feature + if (parentNode.moveBefore) { + // @ts-ignore - use proposed moveBefore feature + parentNode.moveBefore(element, after); + } else { + parentNode.insertBefore(element, after); } } diff --git a/test/bootstrap.js b/test/bootstrap.js index f4cd785..fc1bfab 100644 --- a/test/bootstrap.js +++ b/test/bootstrap.js @@ -35,25 +35,49 @@ describe("Bootstrap test", function () { it("basic deep morph works", function (done) { let div1 = make( - '
A
B
C
', + ` +
+
+
A
+
+
+
B
+
+
+
C
+
+
`.trim(), ); let d1 = div1.querySelector("#d1"); let d2 = div1.querySelector("#d2"); let d3 = div1.querySelector("#d3"); - let morphTo = - '
E
F
D
'; + let morphTo = ` +
+
+
E
+
+
+
F
+
+
+
D
+
+
`.trim(); let div2 = make(morphTo); print(div1); Idiomorph.morph(div1, div2); print(div1); - // first paragraph should have been discarded in favor of later matches - d1.innerHTML.should.not.equal("D"); - - // second and third paragraph should have morphed + if (Idiomorph.defaults.twoPass) { + // all three paragraphs should have been morphed in twoPass mode + d1.innerHTML.should.equal("D"); + } else { + // default mode deletes and re-adds + d1.innerHTML.should.not.equal("D"); + } d2.innerHTML.should.equal("E"); d3.innerHTML.should.equal("F"); diff --git a/test/two-pass.js b/test/two-pass.js index f702a4e..e699c42 100644 --- a/test/two-pass.js +++ b/test/two-pass.js @@ -214,16 +214,9 @@ describe("Two-pass option for retaining more state", function () { Idiomorph.morph(div, finalSrc, { morphStyle: "outerHTML", twoPass: true }); getWorkArea().innerHTML.should.equal(finalSrc); - if (document.body.moveBefore) { - document.activeElement.outerHTML.should.equal( - document.getElementById("first").outerHTML, - ); - } else { - document.activeElement.outerHTML.should.equal(document.body.outerHTML); - console.log( - "preserves focus state with two-pass option and outerHTML morphStyle test needs moveBefore enabled to work properly", - ); - } + document.activeElement.outerHTML.should.equal( + document.getElementById("first").outerHTML, + ); }); it("preserves focus state when elements are moved to different levels of the DOM", function () { @@ -337,16 +330,9 @@ describe("Two-pass option for retaining more state", function () { }); getWorkArea().innerHTML.should.equal(finalSrc); - if (document.body.moveBefore) { - document.activeElement.outerHTML.should.equal( - document.getElementById("first").outerHTML, - ); - } else { - document.activeElement.outerHTML.should.equal(document.body.outerHTML); - console.log( - "preserves focus state when parents are reorderd test needs moveBefore enabled to work properly", - ); - } + document.activeElement.outerHTML.should.equal( + document.getElementById("first").outerHTML, + ); }); it("hooks work as expected", function () { @@ -409,11 +395,6 @@ describe("Two-pass option for retaining more state", function () { ``, ``, ], - [ - "after", - finalSrc, - '
\n \n
', - ], [ "before", ``, @@ -424,6 +405,11 @@ describe("Two-pass option for retaining more state", function () { ``, ``, ], + [ + "after", + '
\n \n \n
', + '
\n \n \n
', + ], ]); }); From 8dc5d277337a2dd4372bd720d4aadfca4d5d98b0 Mon Sep 17 00:00:00 2001 From: Micah Geisel Date: Tue, 24 Dec 2024 16:50:52 -0600 Subject: [PATCH 4/7] type-checker appeasement. --- src/idiomorph.js | 76 ++++++++++++++++++++++++++++++++++++------------ 1 file changed, 58 insertions(+), 18 deletions(-) diff --git a/src/idiomorph.js b/src/idiomorph.js index 97c17f3..9a53107 100644 --- a/src/idiomorph.js +++ b/src/idiomorph.js @@ -93,8 +93,8 @@ var Idiomorph = (function () { /** * @typedef {object} MorphContext * - * @property {Node} target - * @property {Node} newContent + * @property {Element} target + * @property {Element} newContent * @property {ConfigInternal} config * @property {ConfigInternal['morphStyle']} morphStyle * @property {ConfigInternal['ignoreActive']} ignoreActive @@ -302,6 +302,7 @@ var Idiomorph = (function () { } else { syncNodeFrom(newContent, oldNode, ctx); if (!ignoreValueOfActiveElement(oldNode, ctx)) { + // @ts-ignore newContent can be a node here because .firstChild will be null morphChildren(newContent, oldNode, ctx); } } @@ -329,8 +330,8 @@ var Idiomorph = (function () { * The two search algorithms terminate if competing node matches appear to outweigh what can be achieved * with the current node. See findIdSetMatch() and findSoftMatch() for details. * - * @param {Node} newParent the parent element of the new content - * @param {Node} oldParent the old content that we are merging the new content into + * @param {Element} newParent the parent element of the new content + * @param {Element} oldParent the old content that we are merging the new content into * @param {MorphContext} ctx the merge context * @returns {void} */ @@ -339,7 +340,9 @@ var Idiomorph = (function () { newParent instanceof HTMLTemplateElement && oldParent instanceof HTMLTemplateElement ) { + // @ts-ignore we can pretend the DocumentFragment is an Element newParent = newParent.content; + // @ts-ignore ditto oldParent = oldParent.content; } @@ -366,7 +369,12 @@ var Idiomorph = (function () { ctx.config.twoPass && ctx.persistentIds.has(/** @type {Element} */ (newChild).id) ) { - const movedChild = moveBeforeById(oldParent, newChild.id, null, ctx); + const movedChild = moveBeforeById( + oldParent, + /** @type {Element} */ (newChild).id, + null, + ctx, + ); morphOldNodeTo(movedChild, newChild, ctx); } else { if (ctx.callbacks.beforeNodeAdded(newChild) === false) continue; @@ -446,7 +454,7 @@ var Idiomorph = (function () { ) { const movedChild = moveBeforeById( oldParent, - newChild.id, + /** @type {Element} */ (newChild).id, insertionPoint, ctx, ); @@ -563,6 +571,7 @@ var Idiomorph = (function () { if (!(from instanceof Element && to instanceof Element)) return; // @ts-ignore this function is only used on boolean attrs that are reflected as dom properties const fromLiveValue = from[attributeName], + // @ts-ignore ditto toLiveValue = to[attributeName]; if (fromLiveValue !== toLiveValue) { let ignoreUpdate = ignoreAttribute(attributeName, to, "update", ctx); @@ -1222,7 +1231,7 @@ var Idiomorph = (function () { hasPersistentIdNodes(ctx, node) && node instanceof Element ) { - moveBefore(ctx.pantry, node); + moveBefore(ctx.pantry, node, null); } else { if (ctx.callbacks.beforeNodeRemoved(node) === false) return; node.parentNode?.removeChild(node); @@ -1230,13 +1239,36 @@ var Idiomorph = (function () { } } + /** + * Search for an element by id within the document and pantry, and move it using moveBefore. + * + * @param {Element} parentNode - The parent node to which the element will be moved. + * @param {string} id - The ID of the element to be moved. + * @param {Node | null} after - The reference node to insert the element before. + * If `null`, the element is appended as the last child. + * @param {MorphContext} ctx + * @returns {Element} The found element + */ function moveBeforeById(parentNode, id, after, ctx) { const target = - ctx.target.querySelector(`#${id}`) || ctx.pantry.querySelector(`#${id}`); + /** @type {Element} - will always be found */ + ( + ctx.target.querySelector(`#${id}`) || ctx.pantry.querySelector(`#${id}`) + ); moveBefore(parentNode, target, after); return target; } + /** + * Moves an element before another element within the same parent. + * Uses the proposed `moveBefore` API if available, otherwise falls back to `insertBefore`. + * This is essentialy a forward-compat wrapper. + * + * @param {Element} parentNode - The parent node containing the after element. + * @param {Node} element - The element to be moved. + * @param {Node | null} after - The reference node to insert `element` before. + * If `null`, `element` is appended as the last child. + */ function moveBefore(parentNode, element, after) { // @ts-ignore - use proposed moveBefore feature if (parentNode.moveBefore) { @@ -1325,12 +1357,12 @@ var Idiomorph = (function () { * @param {Element} content * @returns {Element[]} */ - function nodesWithIds(content) { - let nodes = Array.from(content.querySelectorAll("[id]")); + function elementsWithIds(content) { + let elements = Array.from(content.querySelectorAll("[id]")); if (content.id) { - nodes.push(content); + elements.push(content); } - return nodes; + return elements; } /** @@ -1343,7 +1375,7 @@ var Idiomorph = (function () { */ function populateIdMapForNode(node, idMap) { let nodeParent = node.parentElement; - for (const elt of nodesWithIds(node)) { + for (const elt of elementsWithIds(node)) { /** * @type {Element|null} */ @@ -1390,18 +1422,26 @@ var Idiomorph = (function () { * @returns {Set} the id set of all persistent nodes that exist in both old and new content */ function createPersistentIds(oldContent, newContent) { - const toIdTagName = (node) => node.tagName + "#" + node.id; - const oldIdSet = new Set(nodesWithIds(oldContent).map(toIdTagName)); + const oldIdSet = new Set(elementsWithIds(oldContent).map(toIdTagName)); let matchIdSet = new Set(); - for (const newNode of nodesWithIds(newContent)) { - if (oldIdSet.has(toIdTagName(newNode))) { - matchIdSet.add(newNode.id); + for (const newElement of elementsWithIds(newContent)) { + if (oldIdSet.has(toIdTagName(newElement))) { + matchIdSet.add(newElement.id); } } return matchIdSet; } + /** + * Generates a string in the format "TAGNAME#id" for a given DOM element. + * + * @param {Element} element - The DOM element to generate the string for. + * @returns {string} The generated string in the format "TAGNAME#id". + */ + function toIdTagName(element) { + return element.tagName + "#" + element.id; + } //============================================================================= // This is what ends up becoming the Idiomorph global object //============================================================================= From 6efe380113218e242e228a3bd3d5b8c8956c1c9b Mon Sep 17 00:00:00 2001 From: Micah Geisel Date: Wed, 25 Dec 2024 10:32:16 -0600 Subject: [PATCH 5/7] plug a hole in test coverage. --- test/two-pass.js | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/test/two-pass.js b/test/two-pass.js index e699c42..98a7ca7 100644 --- a/test/two-pass.js +++ b/test/two-pass.js @@ -195,6 +195,39 @@ describe("Two-pass option for retaining more state", function () { states.should.eql([true, true]); }); + it("preserves focus state when focused element is moved", function () { + getWorkArea().innerHTML = ` +
+ +
+
+ +
+ `; + document.getElementById("second").focus(); + + let finalSrc = ` +
+ + +
+ `; + Idiomorph.morph(getWorkArea(), finalSrc, { morphStyle: "innerHTML", twoPass: true }); + + getWorkArea().innerHTML.should.equal(finalSrc); + if (document.body.moveBefore) { + document.activeElement.outerHTML.should.equal( + document.getElementById("second").outerHTML, + ); + } else { + document.activeElement.outerHTML.should.equal(document.body.outerHTML); + console.log( + "preserves focus state when focused element is moved needs moveBefore enabled to work properly", + ); + } + }); + + it("preserves focus state with two-pass option and outerHTML morphStyle", function () { const div = make(`
From 9aed82cf94bf306e3cda04c729f551ff05fcd2eb Mon Sep 17 00:00:00 2001 From: Micah Geisel Date: Wed, 25 Dec 2024 16:27:27 -0600 Subject: [PATCH 6/7] Preserve active element's focus and selection by morphing around it, when possible. --- src/idiomorph.js | 113 ++++++++++++++++++++++++++++++++++------------- test/two-pass.js | 109 +++++++++++++++++++++++++++------------------ 2 files changed, 148 insertions(+), 74 deletions(-) diff --git a/src/idiomorph.js b/src/idiomorph.js index 9a53107..8c16afc 100644 --- a/src/idiomorph.js +++ b/src/idiomorph.js @@ -99,6 +99,7 @@ var Idiomorph = (function () { * @property {ConfigInternal['morphStyle']} morphStyle * @property {ConfigInternal['ignoreActive']} ignoreActive * @property {ConfigInternal['ignoreActiveValue']} ignoreActiveValue + * @property {Map} activeElementMap * @property {Map>} idMap * @property {Set} persistentIds * @property {Set} deadIds @@ -346,22 +347,20 @@ var Idiomorph = (function () { oldParent = oldParent.content; } - /** - * - * @type {Node | null} - */ - let nextNewChild = newParent.firstChild; - /** - * - * @type {Node | null} - */ - let insertionPoint = oldParent.firstChild; - let newChild; + let insertionPoint = /** @type {Node | null} */ (oldParent.firstChild); + let newChild = /** @type {Node} */ ({ nextSibling: newParent.firstChild }); // run through all the new content - while (nextNewChild) { - newChild = nextNewChild; - nextNewChild = newChild.nextSibling; + while ((newChild = /** @type {Node} */ (newChild.nextSibling))) { + // shift upcoming elements around to preserve the active element path if needed + if (ctx.activeElementMap.size) { + insertionPoint = preserveActiveElementPath( + oldParent, + insertionPoint, + newChild, + ctx, + ); + } // if we are at the end of the exiting parent's children, just append if (insertionPoint == null) { @@ -378,8 +377,10 @@ var Idiomorph = (function () { morphOldNodeTo(movedChild, newChild, ctx); } else { if (ctx.callbacks.beforeNodeAdded(newChild) === false) continue; - oldParent.appendChild(newChild); - ctx.callbacks.afterNodeAdded(newChild); + // clone as to not mutate newParent + const newClonedChild = document.importNode(newChild, true); + oldParent.appendChild(newClonedChild); + ctx.callbacks.afterNodeAdded(newClonedChild); } removeIdsFromConsideration(ctx, newChild); continue; @@ -414,10 +415,8 @@ var Idiomorph = (function () { continue; } - if ( - ctx.config.twoPass && // maybe we can run this in default mode too? - isSoftMatch(insertionPoint, newChild) - ) { + // if the current node is a soft match then morph + if (isSoftMatch(insertionPoint, newChild)) { morphOldNodeTo(insertionPoint, newChild, ctx); insertionPoint = insertionPoint.nextSibling; removeIdsFromConsideration(ctx, newChild); @@ -461,27 +460,46 @@ var Idiomorph = (function () { morphOldNodeTo(movedChild, newChild, ctx); } else { if (ctx.callbacks.beforeNodeAdded(newChild) === false) continue; - if (ctx.config.twoPass) { - // maybe this should be a bugfix and in both modes? - const newClonedChild = document.importNode(newChild, true); // clone as to not mutate newParent - oldParent.insertBefore(newClonedChild, insertionPoint); - ctx.callbacks.afterNodeAdded(newClonedChild); - } else { - oldParent.insertBefore(newChild, insertionPoint); - ctx.callbacks.afterNodeAdded(newChild); - } + // clone as to not mutate newParent + const newClonedChild = document.importNode(newChild, true); + oldParent.insertBefore(newClonedChild, insertionPoint); + ctx.callbacks.afterNodeAdded(newClonedChild); } removeIdsFromConsideration(ctx, newChild); } // remove any remaining old nodes that didn't match up with new content - while (insertionPoint !== null) { + while (insertionPoint != null) { let tempNode = insertionPoint; insertionPoint = insertionPoint.nextSibling; removeNode(tempNode, ctx); } } + /** + * @param {Element} oldParent + * @param {Node | null} insertionPoint + * @param {Node} newChild + * @param {MorphContext} ctx + * @returns {Node | null} + * If we're about to try to morph the active element or its ancestor, indirectly "move" + * it into place first, so that the rest of the main loop doesn't actually move it. + */ + function preserveActiveElementPath(oldParent, insertionPoint, newChild, ctx) { + const [activeElement, newActiveElement] = + ctx.activeElementMap.get(oldParent) || []; + // are we about to morph the active element or its ancestor? + if (newActiveElement === newChild) { + while (insertionPoint && insertionPoint !== activeElement) { + // "move" the active element to the left by moving the current node to end of the parent + let nextInsertionPoint = insertionPoint.nextSibling; + moveBefore(oldParent, insertionPoint, null); + insertionPoint = nextInsertionPoint; + } + } + return insertionPoint; + } + //============================================================================= // Attribute Syncing Code //============================================================================= @@ -828,6 +846,9 @@ var Idiomorph = (function () { morphStyle: mergedConfig.morphStyle, ignoreActive: mergedConfig.ignoreActive, ignoreActiveValue: mergedConfig.ignoreActiveValue, + activeElementMap: + (mergedConfig.twoPass && createActiveElementMap(oldNode, newContent)) || + new Map(), idMap: createIdMap(oldNode, newContent), deadIds: new Set(), persistentIds: mergedConfig.twoPass @@ -841,6 +862,38 @@ var Idiomorph = (function () { }; } + /** + * + * @param {Node} oldNode + * @param {Node} newContent + * @returns {Map | undefined} + * Checks to see if its possible to preserve the focused element by morphing around it, + * and if so, provides a map to so. + */ + function createActiveElementMap(oldNode, newContent) { + // @ts-ignore - check for proposed moveBefore feature + if (document.body.moveBefore) return; // don't bother if we have moveBefore + let active = /** @type { Element } */ (document.activeElement); + if (active === document.body) return; + if (!oldNode.contains(active)) return; + if (!active.id) return; // TODO: handle anonymous activeElement? + // @ts-ignore we're checking for existing of query selector first, so settle down + let match = newContent.querySelector?.(`#${active.id}`); + if (!match) return; + + // build the path from the roots to the active elements + let map = new Map(); + while (active !== oldNode && toIdTagName(active) === toIdTagName(match)) { + map.set(active.parentNode, [active, match]); + active = /** @type { Element } */ (active.parentNode); + match = /** @type { Element } */ (match.parentNode); + } + + // only return the map if its a viable path, + // i.e. both active and match made it all the way to their respective roots together + if (map.has(oldNode)) return map; + } + function createPantry() { const pantry = document.createElement("div"); pantry.hidden = true; diff --git a/test/two-pass.js b/test/two-pass.js index 98a7ca7..12d5801 100644 --- a/test/two-pass.js +++ b/test/two-pass.js @@ -195,60 +195,53 @@ describe("Two-pass option for retaining more state", function () { states.should.eql([true, true]); }); - it("preserves focus state when focused element is moved", function () { - getWorkArea().innerHTML = ` + it("preserves focus state with two-pass option and outerHTML morphStyle", function () { + const div = make(`
-
-
- `; - document.getElementById("second").focus(); + `); + getWorkArea().append(div); + document.getElementById("first").focus(); let finalSrc = `
- +
`; - Idiomorph.morph(getWorkArea(), finalSrc, { morphStyle: "innerHTML", twoPass: true }); + Idiomorph.morph(div, finalSrc, { morphStyle: "outerHTML", twoPass: true }); getWorkArea().innerHTML.should.equal(finalSrc); - if (document.body.moveBefore) { - document.activeElement.outerHTML.should.equal( - document.getElementById("second").outerHTML, - ); - } else { - document.activeElement.outerHTML.should.equal(document.body.outerHTML); - console.log( - "preserves focus state when focused element is moved needs moveBefore enabled to work properly", - ); - } + document.activeElement.outerHTML.should.equal( + document.getElementById("first").outerHTML, + ); }); - - it("preserves focus state with two-pass option and outerHTML morphStyle", function () { - const div = make(` + it("preserves focus state when previous element is replaced", function () { + getWorkArea().innerHTML = `
- - + +
- `); - getWorkArea().append(div); - document.getElementById("first").focus(); + `; + document.getElementById("focus").focus(); let finalSrc = `
- - + +
`; - Idiomorph.morph(div, finalSrc, { morphStyle: "outerHTML", twoPass: true }); + Idiomorph.morph(getWorkArea(), finalSrc, { + morphStyle: "innerHTML", + twoPass: true, + }); getWorkArea().innerHTML.should.equal(finalSrc); document.activeElement.outerHTML.should.equal( - document.getElementById("first").outerHTML, + document.getElementById("focus").outerHTML, ); }); @@ -289,7 +282,35 @@ describe("Two-pass option for retaining more state", function () { } }); - it("preserves focus state when elements are moved between different containers", function () { + it("preserves focus state when focused element is moved between anonymous containers", function () { + getWorkArea().innerHTML = ` +
+ +
+
+ +
+ `; + document.getElementById("second").focus(); + + let finalSrc = ` +
+ + +
+ `; + Idiomorph.morph(getWorkArea(), finalSrc, { + morphStyle: "innerHTML", + twoPass: true, + }); + + getWorkArea().innerHTML.should.equal(finalSrc); + document.activeElement.outerHTML.should.equal( + document.getElementById("second").outerHTML, + ); + }); + + it("preserves focus state when elements are moved between IDed containers", function () { getWorkArea().append( make(`
@@ -327,33 +348,33 @@ describe("Two-pass option for retaining more state", function () { } else { document.activeElement.outerHTML.should.equal(document.body.outerHTML); console.log( - "preserves focus state when elements are moved between different containers test needs moveBefore enabled to work properly", + "preserves focus state when elements are moved between IDed containers test needs moveBefore enabled to work properly", ); } }); - it("preserves focus state when parents are reorderd", function () { + it("preserves focus state when parents are reordered", function () { getWorkArea().append( make(`
-
- +
+
- `), ); - document.getElementById("first").focus(); + document.getElementById("focus").focus(); let finalSrc = `
-