package.dist.idiomorph.esm.js Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of idiomorph Show documentation
Show all versions of idiomorph Show documentation
an id-based DOM morphing library
The newest version!
/**
* @typedef {object} ConfigHead
*
* @property {'merge' | 'append' | 'morph' | 'none'} [style]
* @property {boolean} [block]
* @property {boolean} [ignore]
* @property {function(Element): boolean} [shouldPreserve]
* @property {function(Element): boolean} [shouldReAppend]
* @property {function(Element): boolean} [shouldRemove]
* @property {function(Element, {added: Node[], kept: Element[], removed: Element[]}): void} [afterHeadMorphed]
*/
/**
* @typedef {object} ConfigCallbacks
*
* @property {function(Node): boolean} [beforeNodeAdded]
* @property {function(Node): void} [afterNodeAdded]
* @property {function(Element, Node): boolean} [beforeNodeMorphed]
* @property {function(Element, Node): void} [afterNodeMorphed]
* @property {function(Element): boolean} [beforeNodeRemoved]
* @property {function(Element): void} [afterNodeRemoved]
* @property {function(string, Element, "update" | "remove"): boolean} [beforeAttributeUpdated]
* @property {function(Element): boolean} [beforeNodePantried]
*/
/**
* @typedef {object} Config
*
* @property {'outerHTML' | 'innerHTML'} [morphStyle]
* @property {boolean} [ignoreActive]
* @property {boolean} [ignoreActiveValue]
* @property {ConfigCallbacks} [callbacks]
* @property {ConfigHead} [head]
*/
/**
* @typedef {function} NoOp
*
* @returns {void}
*/
/**
* @typedef {object} ConfigHeadInternal
*
* @property {'merge' | 'append' | 'morph' | 'none'} style
* @property {boolean} [block]
* @property {boolean} [ignore]
* @property {(function(Element): boolean) | NoOp} shouldPreserve
* @property {(function(Element): boolean) | NoOp} shouldReAppend
* @property {(function(Element): boolean) | NoOp} shouldRemove
* @property {(function(Element, {added: Node[], kept: Element[], removed: Element[]}): void) | NoOp} afterHeadMorphed
*/
/**
* @typedef {object} ConfigCallbacksInternal
*
* @property {(function(Node): boolean) | NoOp} beforeNodeAdded
* @property {(function(Node): void) | NoOp} afterNodeAdded
* @property {(function(Node, Node): boolean) | NoOp} beforeNodeMorphed
* @property {(function(Node, Node): void) | NoOp} afterNodeMorphed
* @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
*/
/**
* @typedef {object} ConfigInternal
*
* @property {'outerHTML' | 'innerHTML'} morphStyle
* @property {boolean} [ignoreActive]
* @property {boolean} [ignoreActiveValue]
* @property {ConfigCallbacksInternal} callbacks
* @property {ConfigHeadInternal} head
* @property {boolean} [twoPass]
*/
/**
* @typedef {Function} Morph
*
* @param {Element | Document} oldNode
* @param {Element | Node | HTMLCollection | Node[] | string | null} newContent
* @param {Config} [config]
* @returns {undefined | Node[]}
*/
// base IIFE to define idiomorph
/**
*
* @type {{defaults: ConfigInternal, morph: Morph}}
*/
var Idiomorph = (function () {
"use strict";
/**
* @typedef {object} MorphContext
*
* @property {Node} target
* @property {Node} newContent
* @property {ConfigInternal} config
* @property {ConfigInternal['morphStyle']} morphStyle
* @property {ConfigInternal['ignoreActive']} ignoreActive
* @property {ConfigInternal['ignoreActiveValue']} ignoreActiveValue
* @property {Map>} idMap
* @property {Set} persistentIds
* @property {Set} deadIds
* @property {ConfigInternal['callbacks']} callbacks
* @property {ConfigInternal['head']} head
* @property {HTMLDivElement} pantry
*/
//=============================================================================
// AND NOW IT BEGINS...
//=============================================================================
/**
*
* @type {Set}
*/
let EMPTY_SET = new Set();
/**
* Default configuration values, updatable by users now
* @type {ConfigInternal}
*/
let defaults = {
morphStyle: "outerHTML",
callbacks: {
beforeNodeAdded: noOp,
afterNodeAdded: noOp,
beforeNodeMorphed: noOp,
afterNodeMorphed: noOp,
beforeNodeRemoved: noOp,
afterNodeRemoved: noOp,
beforeAttributeUpdated: noOp,
beforeNodePantried: noOp,
},
head: {
style: "merge",
shouldPreserve: function (elt) {
return elt.getAttribute("im-preserve") === "true";
},
shouldReAppend: function (elt) {
return elt.getAttribute("im-re-append") === "true";
},
shouldRemove: noOp,
afterHeadMorphed: noOp,
},
};
/**
* =============================================================================
* Core Morphing Algorithm - morph, morphNormalizedContent, morphOldNodeTo, morphChildren
* =============================================================================
*
* @param {Element | Document} oldNode
* @param {Element | Node | HTMLCollection | Node[] | string | null} newContent
* @param {Config} [config]
* @returns {undefined | Node[]}
*/
function morph(oldNode, newContent, config = {}) {
if (oldNode instanceof Document) {
oldNode = oldNode.documentElement;
}
if (typeof newContent === "string") {
newContent = parseContent(newContent);
}
let normalizedContent = normalizeContent(newContent);
let ctx = createMorphContext(oldNode, normalizedContent, config);
return morphNormalizedContent(oldNode, normalizedContent, ctx);
}
/**
*
* @param {Element} oldNode
* @param {Element} normalizedNewContent
* @param {MorphContext} ctx
* @returns {undefined | Node[]}
*/
function morphNormalizedContent(oldNode, normalizedNewContent, ctx) {
if (ctx.head.block) {
let oldHead = oldNode.querySelector("head");
let newHead = normalizedNewContent.querySelector("head");
if (oldHead && newHead) {
let promises = handleHeadElement(newHead, oldHead, ctx);
// when head promises resolve, call morph again, ignoring the head tag
Promise.all(promises).then(function () {
morphNormalizedContent(
oldNode,
normalizedNewContent,
Object.assign(ctx, {
head: {
block: false,
ignore: true,
},
}),
);
});
return;
}
}
if (ctx.morphStyle === "innerHTML") {
// innerHTML, so we are only updating the children
morphChildren(normalizedNewContent, oldNode, ctx);
if (ctx.config.twoPass) {
restoreFromPantry(oldNode, ctx);
}
return Array.from(oldNode.children);
} else if (ctx.morphStyle === "outerHTML" || ctx.morphStyle == null) {
// otherwise find the best element match in the new content, morph that, and merge its siblings
// into either side of the best match
let bestMatch = findBestNodeMatch(normalizedNewContent, oldNode, ctx);
// stash the siblings that will need to be inserted on either side of the best match
let previousSibling = bestMatch?.previousSibling ?? null;
let nextSibling = bestMatch?.nextSibling ?? null;
// morph it
let morphedNode = morphOldNodeTo(oldNode, bestMatch, ctx);
if (bestMatch) {
// if there was a best match, merge the siblings in too and return the
// whole bunch
if (morphedNode) {
const elements = insertSiblings(
previousSibling,
morphedNode,
nextSibling,
);
if (ctx.config.twoPass) {
restoreFromPantry(morphedNode.parentNode, ctx);
}
return elements;
}
} else {
// otherwise nothing was added to the DOM
return [];
}
} else {
throw "Do not understand how to morph style " + ctx.morphStyle;
}
}
/**
* @param {Node} possibleActiveElement
* @param {MorphContext} ctx
* @returns {boolean}
*/
// TODO: ignoreActive and ignoreActiveValue are marked as optional since they are not
// initialised in the default config object. As a result the && in the function body may
// return undefined instead of boolean. Either expand the type of the return value to
// include undefined or wrap the ctx.ignoreActiveValue into a Boolean()
function ignoreValueOfActiveElement(possibleActiveElement, ctx) {
return (
!!ctx.ignoreActiveValue &&
possibleActiveElement === document.activeElement &&
possibleActiveElement !== document.body
);
}
/**
* @param {Node} oldNode root node to merge content into
* @param {Node | null} newContent new content to merge
* @param {MorphContext} ctx the merge context
* @returns {Node | null} the element that ended up in the DOM
*/
function morphOldNodeTo(oldNode, newContent, ctx) {
if (ctx.ignoreActive && oldNode === document.activeElement) {
// don't morph focused element
} else if (newContent == null) {
if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode;
oldNode.parentNode?.removeChild(oldNode);
ctx.callbacks.afterNodeRemoved(oldNode);
return null;
} else if (!isSoftMatch(oldNode, newContent)) {
if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode;
if (ctx.callbacks.beforeNodeAdded(newContent) === false) return oldNode;
oldNode.parentNode?.replaceChild(newContent, oldNode);
ctx.callbacks.afterNodeAdded(newContent);
ctx.callbacks.afterNodeRemoved(oldNode);
return newContent;
} else {
if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false)
return oldNode;
if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) {
// ignore the head element
} else if (
oldNode instanceof HTMLHeadElement &&
ctx.head.style !== "morph"
) {
// ok to cast: if newContent wasn't also a , it would've got caught in the `!isSoftMatch` branch above
handleHeadElement(
/** @type {HTMLHeadElement} */ (newContent),
oldNode,
ctx,
);
} else {
syncNodeFrom(newContent, oldNode, ctx);
if (!ignoreValueOfActiveElement(oldNode, ctx)) {
morphChildren(newContent, oldNode, ctx);
}
}
ctx.callbacks.afterNodeMorphed(oldNode, newContent);
return oldNode;
}
return null;
}
/**
* This is the core algorithm for matching up children. The idea is to use id sets to try to match up
* nodes as faithfully as possible. We greedily match, which allows us to keep the algorithm fast, but
* by using id sets, we are able to better match up with content deeper in the DOM.
*
* Basic algorithm is, for each node in the new content:
*
* - if we have reached the end of the old parent, append the new content
* - if the new content has an id set match with the current insertion point, morph
* - search for an id set match
* - if id set match found, morph
* - otherwise search for a "soft" match
* - if a soft match is found, morph
* - otherwise, prepend the new node before the current insertion point
*
* 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 {MorphContext} ctx the merge context
* @returns {void}
*/
function morphChildren(newParent, oldParent, ctx) {
if (
newParent instanceof HTMLTemplateElement &&
oldParent instanceof HTMLTemplateElement
) {
newParent = newParent.content;
oldParent = oldParent.content;
}
/**
*
* @type {Node | null}
*/
let nextNewChild = newParent.firstChild;
/**
*
* @type {Node | null}
*/
let insertionPoint = oldParent.firstChild;
let newChild;
// run through all the new content
while (nextNewChild) {
newChild = nextNewChild;
nextNewChild = newChild.nextSibling;
// 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);
} else {
if (ctx.callbacks.beforeNodeAdded(newChild) === false) continue;
oldParent.appendChild(newChild);
ctx.callbacks.afterNodeAdded(newChild);
}
removeIdsFromConsideration(ctx, newChild);
continue;
}
// if the current node has an id set match then morph
if (isIdSetMatch(newChild, insertionPoint, ctx)) {
morphOldNodeTo(insertionPoint, newChild, ctx);
insertionPoint = insertionPoint.nextSibling;
removeIdsFromConsideration(ctx, newChild);
continue;
}
// otherwise search forward in the existing old children for an id set match
let idSetMatch = findIdSetMatch(
newParent,
oldParent,
newChild,
insertionPoint,
ctx,
);
// if we found a potential match, remove the nodes until that point and morph
if (idSetMatch) {
insertionPoint = removeNodesBetween(insertionPoint, idSetMatch, ctx);
morphOldNodeTo(idSetMatch, newChild, ctx);
removeIdsFromConsideration(ctx, newChild);
continue;
}
// no id set match found, so scan forward for a soft match for the current node
let softMatch = findSoftMatch(
newParent,
oldParent,
newChild,
insertionPoint,
ctx,
);
// if we found a soft match for the current node, morph
if (softMatch) {
insertionPoint = removeNodesBetween(insertionPoint, softMatch, ctx);
morphOldNodeTo(softMatch, newChild, ctx);
removeIdsFromConsideration(ctx, newChild);
continue;
}
// 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);
} else {
if (ctx.callbacks.beforeNodeAdded(newChild) === false) continue;
oldParent.insertBefore(newChild, insertionPoint);
ctx.callbacks.afterNodeAdded(newChild);
}
removeIdsFromConsideration(ctx, newChild);
}
// remove any remaining old nodes that didn't match up with new content
while (insertionPoint !== null) {
let tempNode = insertionPoint;
insertionPoint = insertionPoint.nextSibling;
removeNode(tempNode, ctx);
}
}
//=============================================================================
// Attribute Syncing Code
//=============================================================================
/**
* @param {string} attr the attribute to be mutated
* @param {Element} to the element that is going to be updated
* @param {"update" | "remove"} updateType
* @param {MorphContext} ctx the merge context
* @returns {boolean} true if the attribute should be ignored, false otherwise
*/
function ignoreAttribute(attr, to, updateType, ctx) {
if (
attr === "value" &&
ctx.ignoreActiveValue &&
to === document.activeElement
) {
return true;
}
return ctx.callbacks.beforeAttributeUpdated(attr, to, updateType) === false;
}
/**
* syncs a given node with another node, copying over all attributes and
* inner element state from the 'from' node to the 'to' node
*
* @param {Node} from the element to copy attributes & state from
* @param {Node} to the element to copy attributes & state to
* @param {MorphContext} ctx the merge context
*/
function syncNodeFrom(from, to, ctx) {
let type = from.nodeType;
// if is an element type, sync the attributes from the
// new node into the new node
if (type === 1 /* element type */) {
const fromEl = /** @type {Element} */ (from);
const toEl = /** @type {Element} */ (to);
const fromAttributes = fromEl.attributes;
const toAttributes = toEl.attributes;
for (const fromAttribute of fromAttributes) {
if (ignoreAttribute(fromAttribute.name, toEl, "update", ctx)) {
continue;
}
if (toEl.getAttribute(fromAttribute.name) !== fromAttribute.value) {
toEl.setAttribute(fromAttribute.name, fromAttribute.value);
}
}
// iterate backwards to avoid skipping over items when a delete occurs
for (let i = toAttributes.length - 1; 0 <= i; i--) {
const toAttribute = toAttributes[i];
// toAttributes is a live NamedNodeMap, so iteration+mutation is unsafe
// e.g. custom element attribute callbacks can remove other attributes
if (!toAttribute) continue;
if (!fromEl.hasAttribute(toAttribute.name)) {
if (ignoreAttribute(toAttribute.name, toEl, "remove", ctx)) {
continue;
}
toEl.removeAttribute(toAttribute.name);
}
}
}
// sync text nodes
if (type === 8 /* comment */ || type === 3 /* text */) {
if (to.nodeValue !== from.nodeValue) {
to.nodeValue = from.nodeValue;
}
}
if (!ignoreValueOfActiveElement(to, ctx)) {
// sync input values
syncInputValue(from, to, ctx);
}
}
/**
* @param {Element} from element to sync the value from
* @param {Element} to element to sync the value to
* @param {string} attributeName the attribute name
* @param {MorphContext} ctx the merge context
*/
function syncBooleanAttribute(from, to, attributeName, ctx) {
// TODO: prefer set/getAttribute here
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],
toLiveValue = to[attributeName];
if (fromLiveValue !== toLiveValue) {
let ignoreUpdate = ignoreAttribute(attributeName, to, "update", ctx);
if (!ignoreUpdate) {
// update attribute's associated DOM property
// @ts-ignore this function is only used on boolean attrs that are reflected as dom properties
to[attributeName] = from[attributeName];
}
if (fromLiveValue) {
if (!ignoreUpdate) {
// TODO: do we really want this? tests say so but it feels wrong
to.setAttribute(attributeName, fromLiveValue);
}
} else {
if (!ignoreAttribute(attributeName, to, "remove", ctx)) {
to.removeAttribute(attributeName);
}
}
}
}
/**
* NB: many bothans died to bring us information:
*
* https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js
* https://github.com/choojs/nanomorph/blob/master/lib/morph.jsL113
*
* @param {Node} from the element to sync the input value from
* @param {Node} to the element to sync the input value to
* @param {MorphContext} ctx the merge context
*/
function syncInputValue(from, to, ctx) {
if (
from instanceof HTMLInputElement &&
to instanceof HTMLInputElement &&
from.type !== "file"
) {
let fromValue = from.value;
let toValue = to.value;
// sync boolean attributes
syncBooleanAttribute(from, to, "checked", ctx);
syncBooleanAttribute(from, to, "disabled", ctx);
if (!from.hasAttribute("value")) {
if (!ignoreAttribute("value", to, "remove", ctx)) {
to.value = "";
to.removeAttribute("value");
}
} else if (fromValue !== toValue) {
if (!ignoreAttribute("value", to, "update", ctx)) {
to.setAttribute("value", fromValue);
to.value = fromValue;
}
}
// TODO: QUESTION(1cg): this used to only check `from` unlike the other branches -- why?
// did I break something?
} else if (
from instanceof HTMLOptionElement &&
to instanceof HTMLOptionElement
) {
syncBooleanAttribute(from, to, "selected", ctx);
} else if (
from instanceof HTMLTextAreaElement &&
to instanceof HTMLTextAreaElement
) {
let fromValue = from.value;
let toValue = to.value;
if (ignoreAttribute("value", to, "update", ctx)) {
return;
}
if (fromValue !== toValue) {
to.value = fromValue;
}
if (to.firstChild && to.firstChild.nodeValue !== fromValue) {
to.firstChild.nodeValue = fromValue;
}
}
}
/**
* =============================================================================
* The HEAD tag can be handled specially, either w/ a 'merge' or 'append' style
* =============================================================================
* @param {Element} newHeadTag
* @param {Element} currentHead
* @param {MorphContext} ctx
* @returns {Promise[]}
*/
function handleHeadElement(newHeadTag, currentHead, ctx) {
/**
* @type {Node[]}
*/
let added = [];
/**
* @type {Element[]}
*/
let removed = [];
/**
* @type {Element[]}
*/
let preserved = [];
/**
* @type {Element[]}
*/
let nodesToAppend = [];
let headMergeStyle = ctx.head.style;
// put all new head elements into a Map, by their outerHTML
let srcToNewHeadNodes = new Map();
for (const newHeadChild of newHeadTag.children) {
srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild);
}
// for each elt in the current head
for (const currentHeadElt of currentHead.children) {
// If the current head element is in the map
let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML);
let isReAppended = ctx.head.shouldReAppend(currentHeadElt);
let isPreserved = ctx.head.shouldPreserve(currentHeadElt);
if (inNewContent || isPreserved) {
if (isReAppended) {
// remove the current version and let the new version replace it and re-execute
removed.push(currentHeadElt);
} else {
// this element already exists and should not be re-appended, so remove it from
// the new content map, preserving it in the DOM
srcToNewHeadNodes.delete(currentHeadElt.outerHTML);
preserved.push(currentHeadElt);
}
} else {
if (headMergeStyle === "append") {
// we are appending and this existing element is not new content
// so if and only if it is marked for re-append do we do anything
if (isReAppended) {
removed.push(currentHeadElt);
nodesToAppend.push(currentHeadElt);
}
} else {
// if this is a merge, we remove this content since it is not in the new head
if (ctx.head.shouldRemove(currentHeadElt) !== false) {
removed.push(currentHeadElt);
}
}
}
}
// Push the remaining new head elements in the Map into the
// nodes to append to the head tag
nodesToAppend.push(...srcToNewHeadNodes.values());
log("to append: ", nodesToAppend);
let promises = [];
for (const newNode of nodesToAppend) {
log("adding: ", newNode);
// TODO: This could theoretically be null, based on type
let newElt = /** @type {ChildNode} */ (
document.createRange().createContextualFragment(newNode.outerHTML)
.firstChild
);
log(newElt);
if (ctx.callbacks.beforeNodeAdded(newElt) !== false) {
if (
("href" in newElt && newElt.href) ||
("src" in newElt && newElt.src)
) {
/** @type {(result?: any) => void} */ let resolve;
let promise = new Promise(function (_resolve) {
resolve = _resolve;
});
newElt.addEventListener("load", function () {
resolve();
});
promises.push(promise);
}
currentHead.appendChild(newElt);
ctx.callbacks.afterNodeAdded(newElt);
added.push(newElt);
}
}
// remove all removed elements, after we have appended the new elements to avoid
// additional network requests for things like style sheets
for (const removedElement of removed) {
if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) {
currentHead.removeChild(removedElement);
ctx.callbacks.afterNodeRemoved(removedElement);
}
}
ctx.head.afterHeadMorphed(currentHead, {
added: added,
kept: preserved,
removed: removed,
});
return promises;
}
//=============================================================================
// Misc
//=============================================================================
/**
* @param {any[]} _args
*/
function log(..._args) {
//console.log(args);
}
function noOp() {}
/**
* Deep merges the config object and the Idiomoroph.defaults object to
* produce a final configuration object
* @param {Config} config
* @returns {ConfigInternal}
*/
function mergeDefaults(config) {
/**
* @type {ConfigInternal}
*/
let finalConfig = Object.assign({}, defaults);
// copy top level stuff into final config
Object.assign(finalConfig, config);
// copy callbacks into final config (do this to deep merge the callbacks)
finalConfig.callbacks = Object.assign(
{},
defaults.callbacks,
config.callbacks,
);
// copy head config into final config (do this to deep merge the head)
finalConfig.head = Object.assign({}, defaults.head, config.head);
return finalConfig;
}
/**
*
* @param {Element} oldNode
* @param {Element} newContent
* @param {Config} config
* @returns {MorphContext}
*/
function createMorphContext(oldNode, newContent, config) {
const mergedConfig = mergeDefaults(config);
return {
target: oldNode,
newContent: newContent,
config: mergedConfig,
morphStyle: mergedConfig.morphStyle,
ignoreActive: mergedConfig.ignoreActive,
ignoreActiveValue: mergedConfig.ignoreActiveValue,
idMap: createIdMap(oldNode, newContent),
deadIds: new Set(),
persistentIds: mergedConfig.twoPass
? createPersistentIds(oldNode, newContent)
: new Set(),
pantry: mergedConfig.twoPass
? createPantry()
: document.createElement("div"),
callbacks: mergedConfig.callbacks,
head: mergedConfig.head,
};
}
function createPantry() {
const pantry = document.createElement("div");
pantry.hidden = true;
document.body.insertAdjacentElement("afterend", pantry);
return pantry;
}
/**
*
* @param {Node | null} node1
* @param {Node | null} node2
* @param {MorphContext} ctx
* @returns {boolean}
*/
// TODO: The function handles this as if it's Element or null, but the function is called in
// places where the arguments may be just a Node, not an Element
function isIdSetMatch(node1, node2, ctx) {
if (node1 == null || node2 == null) {
return false;
}
if (
node1 instanceof Element &&
node2 instanceof Element &&
node1.tagName === node2.tagName
) {
if (node1.id !== "" && node1.id === node2.id) {
return true;
} else {
return getIdIntersectionCount(ctx, node1, node2) > 0;
}
}
return false;
}
/**
*
* @param {Node | null} oldNode
* @param {Node | null} newNode
* @returns {boolean}
*/
function isSoftMatch(oldNode, newNode) {
if (oldNode == null || newNode == null) {
return false;
}
// ok to cast: if one is not element, `id` or `tagName` will be undefined and we'll compare that
// If oldNode has an `id` with possible state and it doesn't match newNode.id then avoid morphing
if (
/** @type {Element} */ (oldNode).id &&
/** @type {Element} */ (oldNode).id !==
/** @type {Element} */ (newNode).id
) {
return false;
}
return (
oldNode.nodeType === newNode.nodeType &&
/** @type {Element} */ (oldNode).tagName ===
/** @type {Element} */ (newNode).tagName
);
}
/**
*
* @param {Node} startInclusive
* @param {Node} endExclusive
* @param {MorphContext} ctx
* @returns {Node | null}
*/
function removeNodesBetween(startInclusive, endExclusive, ctx) {
/** @type {Node | null} */ let cursor = startInclusive;
while (cursor !== endExclusive) {
let tempNode = /** @type {Node} */ (cursor);
// TODO: Prefer assigning to a new variable here or expand the type of startInclusive
// to be Node | null
cursor = tempNode.nextSibling;
removeNode(tempNode, ctx);
}
removeIdsFromConsideration(ctx, endExclusive);
return endExclusive.nextSibling;
}
/**
* =============================================================================
* Scans forward from the insertionPoint in the old parent looking for a potential id match
* for the newChild. We stop if we find a potential id match for the new child OR
* if the number of potential id matches we are discarding is greater than the
* potential id matches for the new child
* =============================================================================
* @param {Node} newContent
* @param {Node} oldParent
* @param {Node} newChild
* @param {Node} insertionPoint
* @param {MorphContext} ctx
* @returns {null | Node}
*/
function findIdSetMatch(
newContent,
oldParent,
newChild,
insertionPoint,
ctx,
) {
// max id matches we are willing to discard in our search
let newChildPotentialIdCount = getIdIntersectionCount(
ctx,
newChild,
oldParent,
);
/**
* @type {Node | null}
*/
let potentialMatch = null;
// only search forward if there is a possibility of an id match
if (newChildPotentialIdCount > 0) {
// TODO: This is ghosting the potentialMatch variable outside of this block.
// Probably an error
potentialMatch = insertionPoint;
// if there is a possibility of an id match, scan forward
// keep track of the potential id match count we are discarding (the
// newChildPotentialIdCount must be greater than this to make it likely
// worth it)
let otherMatchCount = 0;
while (potentialMatch != null) {
// If we have an id match, return the current potential match
if (isIdSetMatch(newChild, potentialMatch, ctx)) {
return potentialMatch;
}
// computer the other potential matches of this new content
otherMatchCount += getIdIntersectionCount(
ctx,
potentialMatch,
newContent,
);
if (otherMatchCount > newChildPotentialIdCount) {
// if we have more potential id matches in _other_ content, we
// do not have a good candidate for an id match, so return null
return null;
}
// advanced to the next old content child
potentialMatch = potentialMatch.nextSibling;
}
}
return potentialMatch;
}
/**
* =============================================================================
* Scans forward from the insertionPoint in the old parent looking for a potential soft match
* for the newChild. We stop if we find a potential soft match for the new child OR
* if we find a potential id match in the old parents children OR if we find two
* potential soft matches for the next two pieces of new content
* =============================================================================
* @param {Node} newContent
* @param {Node} oldParent
* @param {Node} newChild
* @param {Node} insertionPoint
* @param {MorphContext} ctx
* @returns {null | Node}
*/
function findSoftMatch(newContent, oldParent, newChild, insertionPoint, ctx) {
/**
* @type {Node | null}
*/
let potentialSoftMatch = insertionPoint;
/**
* @type {Node | null}
*/
let nextSibling = newChild.nextSibling;
let siblingSoftMatchCount = 0;
while (potentialSoftMatch != null) {
if (getIdIntersectionCount(ctx, potentialSoftMatch, newContent) > 0) {
// the current potential soft match has a potential id set match with the remaining new
// content so bail out of looking
return null;
}
// if we have a soft match with the current node, return it
if (isSoftMatch(potentialSoftMatch, newChild)) {
return potentialSoftMatch;
}
if (isSoftMatch(potentialSoftMatch, nextSibling)) {
// the next new node has a soft match with this node, so
// increment the count of future soft matches
siblingSoftMatchCount++;
// ok to cast: if it was null it couldn't be a soft match
nextSibling = /** @type {Node} */ (nextSibling).nextSibling;
// If there are two future soft matches, bail to allow the siblings to soft match
// so that we don't consume future soft matches for the sake of the current node
if (siblingSoftMatchCount >= 2) {
return null;
}
}
// advanced to the next old content child
potentialSoftMatch = potentialSoftMatch.nextSibling;
}
return potentialSoftMatch;
}
/** @type {WeakSet} */
const generatedByIdiomorph = new WeakSet();
/**
*
* @param {string} newContent
* @returns {Node | null | DocumentFragment}
*/
function parseContent(newContent) {
let parser = new DOMParser();
// remove svgs to avoid false-positive matches on head, etc.
let contentWithSvgsRemoved = newContent.replace(
/
© 2015 - 2025 Weber Informatics LLC | Privacy Policy