diff --git a/src/idiomorph.js b/src/idiomorph.js index b2e42a9..e367891 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 */ /** @@ -95,12 +93,13 @@ 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 * @property {ConfigInternal['ignoreActiveValue']} ignoreActiveValue + * @property {Map} activeElementMap * @property {Map>} idMap * @property {Set} persistentIds * @property {Set} deadIds @@ -133,7 +132,6 @@ var Idiomorph = (function () { beforeNodeRemoved: noOp, afterNodeRemoved: noOp, beforeAttributeUpdated: noOp, - beforeNodePantried: noOp, }, head: { style: "merge", @@ -208,7 +206,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 +231,7 @@ var Idiomorph = (function () { nextSibling, ); if (ctx.config.twoPass) { - restoreFromPantry(morphedNode.parentNode, ctx); + ctx.pantry.remove(); } return elements; } @@ -305,6 +303,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); } } @@ -332,8 +331,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} */ @@ -342,39 +341,46 @@ 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; } - /** - * - * @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) { - // 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, + /** @type {Element} */ (newChild).id, + null, + ctx, + ); + 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; @@ -399,12 +405,24 @@ 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 the current node is a soft match then morph + if (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 +434,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,28 +447,61 @@ 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, + /** @type {Element} */ (newChild).id, + insertionPoint, + ctx, + ); + morphOldNodeTo(movedChild, newChild, ctx); } else { if (ctx.callbacks.beforeNodeAdded(newChild) === false) continue; - 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) || []; + if (!activeElement) return insertionPoint; + // are we about to morph the active element or its ancestor? + if (newActiveElement === newChild) { + const beforePoint = activeElement.nextSibling; + while (insertionPoint && insertionPoint !== activeElement) { + // "move" the active element to the left by moving the current node after the active element + let nextInsertionPoint = insertionPoint.nextSibling; + moveBefore(oldParent, insertionPoint, beforePoint); + insertionPoint = nextInsertionPoint; + } + } + return insertionPoint; + } + //============================================================================= // Attribute Syncing Code //============================================================================= @@ -536,6 +591,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); @@ -792,6 +848,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 @@ -805,6 +864,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; @@ -1184,91 +1275,62 @@ 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); + moveBefore(ctx.pantry, node, null); } else { - if (ctx.callbacks.beforeNodeRemoved(tempNode) === false) return; - tempNode.parentNode?.removeChild(tempNode); - ctx.callbacks.afterNodeRemoved(tempNode); + if (ctx.callbacks.beforeNodeRemoved(node) === false) return; + node.parentNode?.removeChild(node); + ctx.callbacks.afterNodeRemoved(node); } } /** + * Search for an element by id within the document and pantry, and move it using moveBefore. * - * @param {Node} node + * @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 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); - } - } else { - if (ctx.callbacks.beforeNodeRemoved(node) === false) return; - node.parentNode?.removeChild(node); - ctx.callbacks.afterNodeRemoved(node); - } + function moveBeforeById(parentNode, id, after, ctx) { + const target = + /** @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 {Node | null} root - * @param {MorphContext} ctx + * @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 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); - 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); - } - matchElement.remove(); - } - }); - ctx.pantry.remove(); + 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); } } @@ -1350,12 +1412,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; } /** @@ -1368,7 +1430,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} */ @@ -1415,18 +1477,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 //============================================================================= 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 aff806a..12d5801 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( @@ -216,16 +214,35 @@ 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 previous element is replaced", function () { + getWorkArea().innerHTML = ` +
+ + +
+ `; + document.getElementById("focus").focus(); + + let finalSrc = ` +
+ + +
+ `; + Idiomorph.morph(getWorkArea(), finalSrc, { + morphStyle: "innerHTML", + twoPass: true, + }); + + getWorkArea().innerHTML.should.equal(finalSrc); + document.activeElement.outerHTML.should.equal( + document.getElementById("focus").outerHTML, + ); }); it("preserves focus state when elements are moved to different levels of the DOM", function () { @@ -265,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(`
@@ -303,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 = `
-