diff --git a/CHANGELOG.md b/CHANGELOG.md
index a82b008..84ff268 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,9 @@
## Unreleased
+* Removed:
+ * Move the htmx extension out of tree into [htmx-ext-morph](https://github.com/bigskysoftware/htmx-extensions/tree/main/src/morph). (@botandrose) #111
+
## [0.8.0] - 2026-09-05
* Removed:
diff --git a/README.md b/README.md
index dc8abcb..1677ba8 100644
--- a/README.md
+++ b/README.md
@@ -161,59 +161,6 @@ The `head` object also offers callbacks for configuring head merging specifics.
All the behaviors specified above can be set to a different default by mutating the `Idiomorph.defaults` object, including
the `Idiomorph.defaults.callbacks` and `Idiomorph.defaults.head` objects.
-### htmx
-
-Idiomorph was created to integrate with [htmx](https://htmx.org) and can be used as a swapping mechanism by including
-the `dist/idiomorph-ext.js` file in your HTML:
-
-```html
-
-
-
-
-
-
-
-
-
-
-```
-
-or by importing the "idiomorph/htmx" module:
-
-```html
-import "idiomorph/htmx";
-```
-
-Note that this file includes both Idiomorph and the htmx extension.
-
-#### Configuring Morphing Behavior in htmx
-
-The Idiomorph extension for htmx supports three different syntaxes for specifying behavior:
-
-* `hx-swap='morph'` - This will perform a morph on the outerHTML of the target
-* `hx-swap='morph:outerHTML'` - This will perform a morph on the outerHTML of the target (explicit)
-* `hx-swap='morph:innerHTML'` - This will perform a morph on the innerHTML of the target (i.e. the children)
-* `hx-swap='morph:'` - In this form, `` can be any valid JavaScript expression. The results of the expression
- will be passed into the `Idiomorph.morph()` method as the configuration.
-
-The last form gives you access to all the configuration options of Idiomorph. So, for example, if you wanted to ignore
-the input value in a given morph, you could use the following swap specification:
-
-```html
-
-```
-
## Performance
Idiomorph is not designed to be as fast as either morphdom or nanomorph. Rather, its goals are:
diff --git a/ROADMAP.md b/ROADMAP.md
index 37a770b..763fc47 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -11,7 +11,7 @@ This document outlines the development roadmap for Idiomorph. It provides a high
## Milestones
### 0.9.0
-- [ ] Move idiomorph/htmx.js out of tree into an htmx extension https://github.com/bigskysoftware/idiomorph/issues/111
+- [x] Move idiomorph/htmx.js out of tree into an htmx extension https://github.com/bigskysoftware/idiomorph/issues/111
- [ ] Settle input value semantics, and the subtree-skipping speedup they unblock https://github.com/bigskysoftware/idiomorph/issues/144
- [ ] Improve anonymous node matching, perhaps using Merkle trees, or fuzzy synthetic ids? https://github.com/bigskysoftware/idiomorph/issues/143
- [ ] Plugin system? https://github.com/bigskysoftware/idiomorph/issues/109
diff --git a/dist/idiomorph-ext.esm.js b/dist/idiomorph-ext.esm.js
deleted file mode 100644
index 8431b71..0000000
--- a/dist/idiomorph-ext.esm.js
+++ /dev/null
@@ -1,1490 +0,0 @@
-import htmx from "htmx.org";
-
-/**
- * @typedef {object} ConfigHead
- *
- * @property {'merge' | 'append' | 'morph' | 'none'} [style]
- * @property {boolean} [block]
- * @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]
- */
-
-/**
- * @typedef {object} Config
- *
- * @property {'outerHTML' | 'innerHTML'} [morphStyle]
- * @property {boolean} [ignoreActive]
- * @property {boolean} [ignoreActiveValue]
- * @property {boolean} [restoreFocus]
- * @property {ConfigCallbacks} [callbacks]
- * @property {ConfigHead} [head]
- */
-
-/**
- * @callback NoOp
- *
- * @returns {void}
- */
-
-/**
- * @typedef {object} ConfigHeadInternal
- *
- * @property {'merge' | 'append' | 'morph' | 'none'} style
- * @property {boolean} [block]
- * @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
- */
-
-/**
- * @typedef {object} ConfigInternal
- *
- * @property {'outerHTML' | 'innerHTML'} morphStyle
- * @property {boolean} [ignoreActive]
- * @property {boolean} [ignoreActiveValue]
- * @property {boolean} [restoreFocus]
- * @property {ConfigCallbacksInternal} callbacks
- * @property {ConfigHeadInternal} head
- */
-
-/**
- * @typedef {Object} IdElement
- * @property {Element} elt
- * @property {string} id
- */
-
-/**
- * @typedef {Object} IdSets
- * @property {Set} persistentIds
- * @property {Map>} idMap
- */
-
-/**
- * @callback Morph
- *
- * @param {Node} oldNode
- * @param {Node | HTMLCollection | Node[] | string | null} newContent
- * @param {Config} [config]
- * @returns {Promise | Node[]}
- */
-
-// base IIFE to define idiomorph
-/**
- *
- * @type {{defaults: ConfigInternal, morph: Morph}}
- */
-var Idiomorph = (function () {
- "use strict";
-
- /**
- * @typedef {object} MorphContext
- *
- * @property {Element} target
- * @property {Element} newContent
- * @property {Document} doc
- * @property {ConfigInternal} config
- * @property {ConfigInternal['morphStyle']} morphStyle
- * @property {ConfigInternal['ignoreActive']} ignoreActive
- * @property {ConfigInternal['ignoreActiveValue']} ignoreActiveValue
- * @property {ConfigInternal['restoreFocus']} restoreFocus
- * @property {Map>} idMap
- * @property {Set} persistentIds
- * @property {ConfigInternal['callbacks']} callbacks
- * @property {ConfigInternal['head']} head
- * @property {HTMLDivElement} pantry
- * @property {Element[]} activeElementAndParents
- */
-
- //=============================================================================
- // AND NOW IT BEGINS...
- //=============================================================================
-
- const noOp = () => {};
- /**
- * Default configuration values, updatable by users now
- * @type {ConfigInternal}
- */
- const defaults = {
- morphStyle: "outerHTML",
- callbacks: {
- beforeNodeAdded: noOp,
- afterNodeAdded: noOp,
- beforeNodeMorphed: noOp,
- afterNodeMorphed: noOp,
- beforeNodeRemoved: noOp,
- afterNodeRemoved: noOp,
- beforeAttributeUpdated: noOp,
- },
- head: {
- style: "merge",
- shouldPreserve: (elt) => elt.getAttribute("im-preserve") === "true",
- shouldReAppend: (elt) => elt.getAttribute("im-re-append") === "true",
- shouldRemove: noOp,
- afterHeadMorphed: noOp,
- },
- restoreFocus: true,
- };
-
- /**
- * Core idiomorph function for morphing one DOM tree to another
- *
- * @param {Node} oldNode
- * @param {Node | HTMLCollection | Node[] | string | null} newContent
- * @param {Config} [config]
- * @returns {Promise | Node[]}
- */
- function morph(oldNode, newContent, config = {}) {
- const oldElt = normalizeElement(oldNode);
- const newNode = normalizeParent(newContent);
- const ctx = createMorphContext(oldElt, newNode, config);
-
- return withHeadBlocking(
- ctx,
- oldElt,
- newNode,
- /** @param {MorphContext} ctx */ (ctx) => {
- const morphedNodes = saveAndRestoreFocus(ctx, () => {
- if (ctx.morphStyle === "innerHTML") {
- morphChildren(ctx, oldElt, newNode);
- return Array.from(oldElt.childNodes);
- } else {
- return morphOuterHTML(ctx, oldElt, newNode);
- }
- });
- ctx.pantry.remove();
- return morphedNodes;
- },
- );
- }
-
- /**
- * Morph just the outerHTML of the oldNode to the newContent
- * We have to be careful because the oldNode could have siblings which need to be untouched
- * @param {MorphContext} ctx
- * @param {Element} oldNode
- * @param {Element} newNode
- * @returns {Node[]}
- */
- function morphOuterHTML(ctx, oldNode, newNode) {
- const oldParent = normalizeParent(oldNode);
- morphChildren(
- ctx,
- oldParent,
- newNode,
- // these two optional params are the secret sauce
- oldNode, // start point for iteration
- oldNode.nextSibling, // end point for iteration
- );
- // this is safe even with siblings, because normalizeParent returns a SlicedParentNode if needed.
- return Array.from(oldParent.childNodes);
- }
-
- /**
- * @param {MorphContext} ctx
- * @param {Function} fn
- * @returns {Node[]}
- */
- function saveAndRestoreFocus(ctx, fn) {
- if (!ctx.config.restoreFocus) return fn();
- let activeElement =
- /** @type {HTMLInputElement|HTMLTextAreaElement|null} */ (
- ctx.doc.activeElement
- );
-
- // don't bother if the active element is not an input or textarea
- if (
- !(is.inputElement(activeElement) || is.textAreaElement(activeElement))
- ) {
- return fn();
- }
-
- const { id: activeElementId, selectionStart, selectionEnd } = activeElement;
-
- const results = fn();
-
- if (
- activeElementId &&
- activeElementId !== ctx.doc.activeElement?.getAttribute("id")
- ) {
- activeElement = ctx.target.querySelector(
- `[id="${CSS.escape(activeElementId)}"]`,
- );
- activeElement?.focus();
- }
- if (activeElement && !activeElement.selectionEnd && selectionEnd) {
- try {
- activeElement.setSelectionRange(selectionStart, selectionEnd);
- } catch {
- // the element may not support setSelectionRange: it's no longer an
- // input/textarea after the morph, or it's an input type (number,
- // email, date, ...) that doesn't support text selection
- }
- }
-
- return results;
- }
-
- const morphChildren = (function () {
- /**
- * 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:
- * - for each node in the new content:
- * - search self and siblings for an id set match, falling back to a soft match
- * - if match found
- * - remove any nodes up to the match:
- * - pantry persistent nodes
- * - delete the rest
- * - morph the match
- * - elsif no match found, and node is persistent
- * - find its match by querying the old root (future) and pantry (past)
- * - move it and its children here
- * - morph it
- * - else
- * - create a new node from scratch as a last result
- *
- * @param {MorphContext} ctx the merge context
- * @param {Element} oldParent the old content that we are merging the new content into
- * @param {Element} newParent the parent element of the new content
- * @param {Node|null} [insertionPoint] the point in the DOM we start morphing at (defaults to first child)
- * @param {Node|null} [endPoint] the point in the DOM we stop morphing at (defaults to after last child)
- */
- function morphChildren(
- ctx,
- oldParent,
- newParent,
- insertionPoint = null,
- endPoint = null,
- ) {
- // normalize
- if (is.templateElement(oldParent) && is.templateElement(newParent)) {
- // @ts-ignore we can pretend the DocumentFragment is an Element
- oldParent = oldParent.content;
- // @ts-ignore ditto
- newParent = newParent.content;
- }
- insertionPoint ||= oldParent.firstChild;
-
- // run through all the new content
- for (const newChild of newParent.childNodes) {
- // once we reach the end of the old parent content skip to the end and insert the rest
- if (insertionPoint && insertionPoint != endPoint) {
- const bestMatch = findBestMatch(
- ctx,
- newChild,
- insertionPoint,
- endPoint,
- );
- if (bestMatch) {
- // if the node to morph is not at the insertion point then remove/move up to it
- if (bestMatch !== insertionPoint) {
- removeNodesBetween(ctx, insertionPoint, bestMatch);
- }
- morphNode(bestMatch, newChild, ctx);
- insertionPoint = bestMatch.nextSibling;
- continue;
- }
- }
-
- // if the matching node is elsewhere in the original content
- if (is.element(newChild)) {
- // we can pretend the id is non-null because the next `.has` line will reject it if not
- const newChildId = /** @type {String} */ (
- newChild.getAttribute("id")
- );
- if (ctx.persistentIds.has(newChildId)) {
- // move it and all its children here and morph
- const movedChild = moveBeforeById(
- oldParent,
- newChildId,
- insertionPoint,
- ctx,
- );
- morphNode(movedChild, newChild, ctx);
- insertionPoint = movedChild.nextSibling;
- continue;
- }
- }
-
- // last resort: insert the new node from scratch
- const insertedNode = createNode(
- oldParent,
- newChild,
- insertionPoint,
- ctx,
- );
- // could be null if beforeNodeAdded prevented insertion
- if (insertedNode) {
- insertionPoint = insertedNode.nextSibling;
- }
- }
-
- // remove any remaining old nodes that didn't match up with new content
- while (insertionPoint && insertionPoint != endPoint) {
- const tempNode = insertionPoint;
- insertionPoint = insertionPoint.nextSibling;
- removeNode(ctx, tempNode);
- }
- }
-
- /**
- * This performs the action of inserting a new node while handling situations where the node contains
- * elements with persistent ids and possible state info we can still preserve by moving in and then morphing
- *
- * @param {Element} oldParent
- * @param {Node} newChild
- * @param {Node|null} insertionPoint
- * @param {MorphContext} ctx
- * @returns {Node|null}
- */
- function createNode(oldParent, newChild, insertionPoint, ctx) {
- if (ctx.callbacks.beforeNodeAdded(newChild) === false) return null;
- if (ctx.idMap.has(newChild)) {
- // node has children with ids with possible state so create a dummy elt of same type and apply full morph algorithm
- // createElementNS doesn't case-normalize, so localName rather than tagName
- const newEmptyChild = ctx.doc.createElementNS(
- /** @type {Element} */ (newChild).namespaceURI,
- /** @type {Element} */ (newChild).localName,
- );
- oldParent.insertBefore(newEmptyChild, insertionPoint);
- morphNode(newEmptyChild, newChild, ctx);
- ctx.callbacks.afterNodeAdded(newEmptyChild);
- return newEmptyChild;
- } else {
- // optimisation: no id state to preserve so we can just insert a clone of the newChild and its descendants
- const newClonedChild = ctx.doc.importNode(newChild, true); // importNode to not mutate newParent
- oldParent.insertBefore(newClonedChild, insertionPoint);
- ctx.callbacks.afterNodeAdded(newClonedChild);
- return newClonedChild;
- }
- }
-
- //=============================================================================
- // Matching Functions
- //=============================================================================
- const findBestMatch = (function () {
- /**
- * Scans forward from the startPoint to the endPoint looking for a match
- * for the node. It looks for an id set match first, then a soft match.
- * We abort softmatching if we find two future soft matches, to reduce churn.
- * @param {Node} node
- * @param {MorphContext} ctx
- * @param {Node | null} startPoint
- * @param {Node | null} endPoint
- * @returns {Node | null}
- */
- function findBestMatch(ctx, node, startPoint, endPoint) {
- let softMatch = null;
- let nextSibling = node.nextSibling;
- let siblingSoftMatchCount = 0;
-
- let cursor = startPoint;
- while (cursor && cursor != endPoint) {
- // soft matching is a prerequisite for id set matching
- if (isSoftMatch(cursor, node)) {
- if (isIdSetMatch(ctx, cursor, node)) {
- return cursor; // found an id set match, we're done!
- }
-
- // we haven't yet saved a soft match fallback
- if (softMatch === null) {
- // the current soft match will hard match something else in the future, leave it
- if (!ctx.idMap.has(cursor)) {
- // save this as the fallback if we get through the loop without finding a hard match
- softMatch = cursor;
- }
- }
- }
- if (
- softMatch === null &&
- nextSibling &&
- isSoftMatch(cursor, nextSibling)
- ) {
- // The next new node has a soft match with this node, so
- // increment the count of future soft matches
- siblingSoftMatchCount++;
- nextSibling = nextSibling.nextSibling;
-
- // If there are two future soft matches, block soft matching for this node to allow
- // future siblings to soft match. This is to reduce churn in the DOM when an element
- // is prepended.
- if (siblingSoftMatchCount >= 2) {
- softMatch = undefined;
- }
- }
-
- // if the current node contains active element, stop looking for better future matches,
- // because if one is found, this node will be moved to the pantry, reparenting it and thus losing focus
- // @ts-ignore pretend cursor is Element rather than Node, we're just testing for array inclusion
- if (ctx.activeElementAndParents.includes(cursor)) break;
-
- cursor = cursor.nextSibling;
- }
-
- return softMatch || null;
- }
-
- /**
- *
- * @param {MorphContext} ctx
- * @param {Node} oldNode
- * @param {Node} newNode
- * @returns {boolean}
- */
- function isIdSetMatch(ctx, oldNode, newNode) {
- let oldSet = ctx.idMap.get(oldNode);
- let newSet = ctx.idMap.get(newNode);
-
- if (!newSet || !oldSet) return false;
-
- for (const id of oldSet) {
- // a potential match is an id in the new and old nodes that
- // has not already been merged into the DOM
- // But the newNode content we call this on has not been
- // merged yet and we don't allow duplicate IDs so it is simple
- if (newSet.has(id)) {
- return true;
- }
- }
- return false;
- }
-
- /**
- *
- * @param {Node} oldNode
- * @param {Node} newNode
- * @returns {boolean}
- */
- function isSoftMatch(oldNode, newNode) {
- // ok to cast: if one is not element, `id` and `tagName` will be undefined and we'll just compare that.
- const oldElt = /** @type {Element} */ (oldNode);
- const newElt = /** @type {Element} */ (newNode);
-
- return (
- oldElt.nodeType === newElt.nodeType &&
- oldElt.tagName === newElt.tagName &&
- // If oldElt has an `id` with possible state and it doesn't match newElt.id then avoid morphing.
- // We'll still match an anonymous node with an IDed newElt, though, because if it got this far,
- // its not persistent, and new nodes can't have any hidden state.
- // We can't use .id because of form input shadowing, and we can't count on .getAttribute's presence because it could be a document-fragment
- (!oldElt.getAttribute?.("id") ||
- oldElt.getAttribute?.("id") === newElt.getAttribute?.("id"))
- );
- }
-
- return findBestMatch;
- })();
-
- //=============================================================================
- // DOM Manipulation Functions
- //=============================================================================
-
- /**
- * Gets rid of an unwanted DOM node; strategy depends on nature of its reuse:
- * - Persistent nodes will be moved to the pantry for later reuse
- * - Other nodes will have their hooks called, and then are removed
- * @param {MorphContext} ctx
- * @param {Node} node
- */
- function removeNode(ctx, node) {
- // don't accidentally morph the pantry out of existence when morphing the full document
- if (node === ctx.pantry) return;
- // are we going to id set match this later?
- if (ctx.idMap.has(node)) {
- // skip callbacks and move to pantry
- moveBefore(ctx.pantry, node, null);
- } else {
- // remove for realsies
- if (ctx.callbacks.beforeNodeRemoved(node) === false) return;
- node.parentNode?.removeChild(node);
- ctx.callbacks.afterNodeRemoved(node);
- }
- }
-
- /**
- * Remove nodes between the start and end nodes
- * @param {MorphContext} ctx
- * @param {Node} startInclusive
- * @param {Node} endExclusive
- * @returns {Node|null}
- */
- function removeNodesBetween(ctx, startInclusive, endExclusive) {
- /** @type {Node | null} */
- let cursor = startInclusive;
- // remove nodes until the endExclusive node
- while (cursor && cursor !== endExclusive) {
- let tempNode = /** @type {Node} */ (cursor);
- cursor = cursor.nextSibling;
- removeNode(ctx, tempNode);
- }
- return cursor;
- }
-
- /**
- * 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 selector = `[id="${CSS.escape(id)}"]`;
- const target =
- /** @type {Element} - will always be found */
- (
- // ctx.target.id unsafe because of form input shadowing
- // ctx.target could be a document fragment which doesn't have `getAttribute`
- (ctx.target.getAttribute?.("id") === id && ctx.target) ||
- ctx.target.querySelector(selector) ||
- ctx.pantry.querySelector(selector)
- );
- removeElementFromAncestorsIdMaps(target, ctx);
- moveBefore(parentNode, target, after);
- return target;
- }
-
- /**
- * Removes an element from its ancestors' id maps. This is needed when an element is moved from the
- * "future" via `moveBeforeId`. Otherwise, its erstwhile ancestors could be mistakenly moved to the
- * pantry rather than being deleted, preventing their removal hooks from being called.
- *
- * @param {Element} element - element to remove from its ancestors' id maps
- * @param {MorphContext} ctx
- */
- function removeElementFromAncestorsIdMaps(element, ctx) {
- // we know id is non-null String, because this function is only called on elements with ids
- const id = /** @type {String} */ (element.getAttribute("id"));
- /** @ts-ignore - safe to loop in this way **/
- while ((element = element.parentNode)) {
- let idSet = ctx.idMap.get(element);
- if (idSet) {
- idSet.delete(id);
- if (!idSet.size) {
- ctx.idMap.delete(element);
- }
- }
- }
- }
-
- /**
- * Moves an element before another element within the same parent.
- * Uses the `moveBefore` API if available (and working), 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) {
- try {
- // @ts-ignore - use proposed moveBefore feature
- parentNode.moveBefore(element, after);
- } catch (e) {
- // moveBefore throws unless both nodes share a root, e.g. when morphing a detached subtree
- parentNode.insertBefore(element, after);
- }
- } else {
- parentNode.insertBefore(element, after);
- }
- }
-
- return morphChildren;
- })();
-
- //=============================================================================
- // Single Node Morphing Code
- //=============================================================================
- const morphNode = (function () {
- /**
- * @param {Node} oldNode root node to merge content into
- * @param {Node} newContent new content to merge
- * @param {MorphContext} ctx the merge context
- * @returns {Node | null} the element that ended up in the DOM
- */
- function morphNode(oldNode, newContent, ctx) {
- if (ctx.ignoreActive && oldNode === ctx.doc.activeElement) {
- // don't morph focused element
- return null;
- }
-
- if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) {
- return oldNode;
- }
-
- if (is.headElement(oldNode) && ctx.head.style === "none") {
- // ignore the head element
- } else if (is.headElement(oldNode) && ctx.head.style !== "morph") {
- // ok to cast: if newContent wasn't also a , it would've got caught in the `!isSoftMatch` branch above
- handleHeadElement(
- oldNode,
- /** @type {HTMLHeadElement} */ (newContent),
- ctx,
- );
- } else {
- morphAttributes(oldNode, newContent, ctx);
- if (!ignoreValueOfActiveElement(oldNode, ctx)) {
- // @ts-ignore newContent can be a node here because .firstChild will be null
- morphChildren(ctx, oldNode, newContent);
- }
- }
- ctx.callbacks.afterNodeMorphed(oldNode, newContent);
- return oldNode;
- }
-
- /**
- * syncs the oldNode to the newNode, copying over all attributes and
- * inner element state from the newNode to the oldNode
- *
- * @param {Node} oldNode the node to copy attributes & state to
- * @param {Node} newNode the node to copy attributes & state from
- * @param {MorphContext} ctx the merge context
- */
- function morphAttributes(oldNode, newNode, ctx) {
- let type = newNode.nodeType;
-
- // if is an element type, sync the attributes from the
- // new node into the new node
- if (type === 1 /* element type */) {
- const oldElt = /** @type {Element} */ (oldNode);
- const newElt = /** @type {Element} */ (newNode);
-
- const oldAttributes = oldElt.attributes;
- const newAttributes = newElt.attributes;
- for (const newAttribute of newAttributes) {
- if (oldElt.getAttribute(newAttribute.name) === newAttribute.value) {
- continue;
- }
- if (ignoreAttribute(newAttribute.name, oldElt, "update", ctx)) {
- continue;
- }
- // setAttribute drops namespaces and rejects names like `@click`
- oldElt.setAttributeNode(
- /** @type {Attr} */ (newAttribute.cloneNode()),
- );
- }
- // iterate backwards to avoid skipping over items when a delete occurs
- for (let i = oldAttributes.length - 1; 0 <= i; i--) {
- const oldAttribute = oldAttributes[i];
-
- // toAttributes is a live NamedNodeMap, so iteration+mutation is unsafe
- // e.g. custom element attribute callbacks can remove other attributes
- if (!oldAttribute) continue;
-
- if (!newElt.hasAttribute(oldAttribute.name)) {
- if (ignoreAttribute(oldAttribute.name, oldElt, "remove", ctx)) {
- continue;
- }
- oldElt.removeAttribute(oldAttribute.name);
- }
- }
-
- if (!ignoreValueOfActiveElement(oldElt, ctx)) {
- syncInputValue(oldElt, newElt, ctx);
- }
- }
-
- // sync text nodes
- if (type === 8 /* comment */ || type === 3 /* text */) {
- if (oldNode.nodeValue !== newNode.nodeValue) {
- oldNode.nodeValue = newNode.nodeValue;
- }
- }
- }
-
- /**
- * 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 {Element} oldElement the element to sync the input value to
- * @param {Element} newElement the element to sync the input value from
- * @param {MorphContext} ctx the merge context
- */
- function syncInputValue(oldElement, newElement, ctx) {
- if (
- is.inputElement(oldElement) &&
- is.inputElement(newElement) &&
- newElement.type !== "file"
- ) {
- let newValue = newElement.value;
- let oldValue = oldElement.value;
-
- // sync boolean attributes
- syncBooleanAttribute(oldElement, newElement, "checked", ctx);
- syncBooleanAttribute(oldElement, newElement, "disabled", ctx);
-
- if (!newElement.hasAttribute("value")) {
- if (!ignoreAttribute("value", oldElement, "remove", ctx)) {
- oldElement.value = "";
- oldElement.removeAttribute("value");
- }
- } else if (oldValue !== newValue) {
- if (!ignoreAttribute("value", oldElement, "update", ctx)) {
- oldElement.setAttribute("value", newValue);
- oldElement.value = newValue;
- }
- }
- // TODO: QUESTION(1cg): this used to only check `newElement` unlike the other branches -- why?
- // did I break something?
- } else if (is.optionElement(oldElement) && is.optionElement(newElement)) {
- syncBooleanAttribute(oldElement, newElement, "selected", ctx);
- } else if (
- is.textAreaElement(oldElement) &&
- is.textAreaElement(newElement)
- ) {
- let newValue = newElement.value;
- let oldValue = oldElement.value;
- if (ignoreAttribute("value", oldElement, "update", ctx)) {
- return;
- }
- if (newValue !== oldValue) {
- oldElement.value = newValue;
- }
- if (
- oldElement.firstChild &&
- oldElement.firstChild.nodeValue !== newValue
- ) {
- oldElement.firstChild.nodeValue = newValue;
- }
- }
- }
-
- /**
- * @param {Element} oldElement element to write the value to
- * @param {Element} newElement element to read the value from
- * @param {string} attributeName the attribute name
- * @param {MorphContext} ctx the merge context
- */
- function syncBooleanAttribute(oldElement, newElement, attributeName, ctx) {
- // @ts-ignore this function is only used on boolean attrs that are reflected as dom properties
- const newLiveValue = newElement[attributeName],
- // @ts-ignore ditto
- oldLiveValue = oldElement[attributeName];
- if (newLiveValue !== oldLiveValue) {
- const ignoreUpdate = ignoreAttribute(
- attributeName,
- oldElement,
- "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
- oldElement[attributeName] = newElement[attributeName];
- }
- if (newLiveValue) {
- if (!ignoreUpdate) {
- // https://developer.mozilla.org/en-US/docs/Glossary/Boolean/HTML
- // this is the correct way to set a boolean attribute to "true"
- oldElement.setAttribute(attributeName, "");
- }
- } else {
- if (!ignoreAttribute(attributeName, oldElement, "remove", ctx)) {
- oldElement.removeAttribute(attributeName);
- }
- }
- }
- }
-
- /**
- * @param {string} attr the attribute to be mutated
- * @param {Element} element 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, element, updateType, ctx) {
- if (
- attr === "value" &&
- ctx.ignoreActiveValue &&
- element === ctx.doc.activeElement
- ) {
- return true;
- }
- return (
- ctx.callbacks.beforeAttributeUpdated(attr, element, updateType) ===
- false
- );
- }
-
- /**
- * @param {Node} possibleActiveElement
- * @param {MorphContext} ctx
- * @returns {boolean}
- */
- function ignoreValueOfActiveElement(possibleActiveElement, ctx) {
- return (
- !!ctx.ignoreActiveValue &&
- possibleActiveElement === ctx.doc.activeElement &&
- possibleActiveElement !== ctx.doc.body
- );
- }
-
- return morphNode;
- })();
-
- //=============================================================================
- // Head Management Functions
- //=============================================================================
- /**
- * @param {MorphContext} ctx
- * @param {Element} oldNode
- * @param {Element} newNode
- * @param {function} callback
- * @returns {Node[] | Promise}
- */
- function withHeadBlocking(ctx, oldNode, newNode, callback) {
- if (ctx.head.block && ctx.head.style !== "none") {
- const oldHead = oldNode.querySelector("head");
- const newHead = newNode.querySelector("head");
- if (oldHead && newHead) {
- const promises = handleHeadElement(oldHead, newHead, ctx);
- // when head promises resolve, proceed ignoring the head tag
- return Promise.all(promises).then(() => {
- const newCtx = Object.assign(ctx, {
- head: {
- block: false,
- style: "none",
- },
- });
- return callback(newCtx);
- });
- }
- }
- // just proceed if we not head blocking
- return callback(ctx);
- }
-
- /**
- * The HEAD tag can be handled specially, either w/ a 'merge' or 'append' style
- *
- * @param {Element} oldHead
- * @param {Element} newHead
- * @param {MorphContext} ctx
- * @returns {Promise[]}
- */
- function handleHeadElement(oldHead, newHead, ctx) {
- let added = [];
- let removed = [];
- let preserved = [];
- let nodesToAppend = [];
-
- // put all new head elements into a Map, by their outerHTML
- let srcToNewHeadNodes = new Map();
- for (const newHeadChild of newHead.children) {
- srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild);
- }
-
- // for each elt in the current head
- for (const currentHeadElt of oldHead.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 (ctx.head.style === "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());
-
- let promises = [];
- for (const newNode of nodesToAppend) {
- // TODO: This could theoretically be null, based on type
- let newElt = /** @type {ChildNode} */ (
- ctx.doc.createRange().createContextualFragment(newNode.outerHTML)
- .firstChild
- );
- 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);
- }
- oldHead.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) {
- oldHead.removeChild(removedElement);
- ctx.callbacks.afterNodeRemoved(removedElement);
- }
- }
-
- ctx.head.afterHeadMorphed(oldHead, {
- added: added,
- kept: preserved,
- removed: removed,
- });
- return promises;
- }
-
- //=============================================================================
- // Create Morph Context Functions
- //=============================================================================
- const createMorphContext = (function () {
- /**
- *
- * @param {Element} oldNode
- * @param {Element} newContent
- * @param {Config} config
- * @returns {MorphContext}
- */
- function createMorphContext(oldNode, newContent, config) {
- const { persistentIds, idMap } = createIdMaps(oldNode, newContent);
-
- const mergedConfig = mergeDefaults(config);
- const morphStyle = mergedConfig.morphStyle || "outerHTML";
- if (!["innerHTML", "outerHTML"].includes(morphStyle)) {
- throw `Do not understand how to morph style ${morphStyle}`;
- }
- // Text and Comment have no ParentNode methods, so they cannot take innerHTML
- if (morphStyle === "innerHTML" && !oldNode.append) {
- throw `Cannot morph the innerHTML of a ${oldNode.nodeName} node, as it cannot have children`;
- }
-
- const headStyle = mergedConfig.head.style || "merge";
- if (!["merge", "append", "morph", "none"].includes(headStyle)) {
- throw `Do not understand how to morph head style ${headStyle}`;
- }
-
- const doc = oldNode.ownerDocument;
-
- return {
- target: oldNode,
- newContent: newContent,
- doc: doc,
- config: mergedConfig,
- morphStyle: morphStyle,
- ignoreActive: mergedConfig.ignoreActive,
- ignoreActiveValue: mergedConfig.ignoreActiveValue,
- restoreFocus: mergedConfig.restoreFocus,
- idMap: idMap,
- persistentIds: persistentIds,
- pantry: createPantry(doc),
- activeElementAndParents: createActiveElementAndParents(oldNode, doc),
- callbacks: mergedConfig.callbacks,
- head: mergedConfig.head,
- };
- }
-
- /**
- * Deep merges the config object and the Idiomorph.defaults object to
- * produce a final configuration object
- * @param {Config} config
- * @returns {ConfigInternal}
- */
- function mergeDefaults(config) {
- 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 {Document} doc
- * @returns {HTMLDivElement}
- */
- function createPantry(doc) {
- const pantry = doc.createElement("div");
- pantry.hidden = true;
- doc.documentElement.append(pantry);
- return pantry;
- }
-
- /**
- * @param {Element} oldNode
- * @param {Document} doc
- * @returns {Element[]}
- */
- function createActiveElementAndParents(oldNode, doc) {
- /** @type {Element[]} */
- let activeElementAndParents = [];
- let elt = doc.activeElement;
- if (elt?.tagName !== "BODY" && oldNode.contains(elt)) {
- while (elt) {
- activeElementAndParents.push(elt);
- if (elt === oldNode) break;
- elt = elt.parentElement;
- }
- }
- return activeElementAndParents;
- }
-
- /**
- * Returns all elements with a non-empty ID contained within the root node and its
- * descendants, each paired with its id so that it only has to be read once.
- *
- * @param {Node} root
- * @returns {IdElement[]}
- */
- function findIdElements(root) {
- /** @type {IdElement[]} */
- let elements = [];
- // root could be a text or comment node which has no `querySelectorAll`,
- // or a document fragment which has no `getAttribute`
- const rootElt = /** @type {Partial} */ (root);
- for (const elt of rootElt.querySelectorAll?.("[id]") ?? []) {
- // elt.id is unsafe because of form input shadowing, and `id=""` is not persistable
- const id = elt.getAttribute("id");
- if (id) elements.push({ elt, id });
- }
- const rootId = rootElt.getAttribute?.("id");
- if (rootId)
- elements.push({ elt: /** @type {Element} */ (root), id: rootId });
- return elements;
- }
-
- /**
- * A bottom-up algorithm that populates a map of Element -> IdSet.
- * The idSet for a given element is the set of all IDs contained within its subtree.
- * As an optimzation, we filter these IDs through the given list of persistent IDs,
- * because we don't need to bother considering IDed elements that won't be in the new content.
- *
- * @param {Map>} idMap
- * @param {Set} persistentIds
- * @param {Element} root
- * @param {IdElement[]} elements
- */
- function populateIdMapWithTree(idMap, persistentIds, root, elements) {
- for (const { elt, id } of elements) {
- if (persistentIds.has(id)) {
- /** @type {Element|null} */
- let current = elt;
- // walk up the parent hierarchy of that element, adding the id
- // of element to the parent's id set
- while (current) {
- let idSet = idMap.get(current);
- // if the id set doesn't exist, create it and insert it in the map
- if (idSet == null) {
- idSet = new Set();
- idMap.set(current, idSet);
- }
- idSet.add(id);
-
- if (current === root) break;
- current = current.parentElement;
- }
- }
- }
- }
-
- /**
- * This function computes a map of nodes to all ids contained within that node (inclusive of the
- * node). This map can be used to ask if two nodes have intersecting sets of ids, which allows
- * for a looser definition of "matching" than tradition id matching, and allows child nodes
- * to contribute to a parent nodes matching.
- *
- * @param {Element} oldContent the old content that will be morphed
- * @param {Element} newContent the new content to morph to
- * @returns {IdSets}
- */
- function createIdMaps(oldContent, newContent) {
- const oldIdElements = findIdElements(oldContent);
- const newIdElements = findIdElements(newContent);
-
- const persistentIds = createPersistentIds(oldIdElements, newIdElements);
-
- /** @type {Map>} */
- let idMap = new Map();
- populateIdMapWithTree(idMap, persistentIds, oldContent, oldIdElements);
-
- /** @ts-ignore - if newContent is a duck-typed parent, pass its single child node as the root to halt upwards iteration */
- const newRoot = newContent.__idiomorphRoot || newContent;
- populateIdMapWithTree(idMap, persistentIds, newRoot, newIdElements);
-
- return { persistentIds, idMap };
- }
-
- /**
- * This function computes the set of ids that persist between the two contents excluding duplicates
- *
- * @param {IdElement[]} oldIdElements
- * @param {IdElement[]} newIdElements
- * @returns {Set}
- */
- function createPersistentIds(oldIdElements, newIdElements) {
- let duplicateIds = new Set();
-
- /** @type {Map} */
- let oldIdTagNameMap = new Map();
- for (const { elt, id } of oldIdElements) {
- if (oldIdTagNameMap.has(id)) {
- duplicateIds.add(id);
- } else {
- oldIdTagNameMap.set(id, elt.tagName);
- }
- }
-
- let persistentIds = new Set();
- for (const { elt, id } of newIdElements) {
- if (persistentIds.has(id)) {
- duplicateIds.add(id);
- } else if (oldIdTagNameMap.get(id) === elt.tagName) {
- persistentIds.add(id);
- }
- // skip if tag types mismatch because its not possible to morph one tag into another
- }
-
- for (const id of duplicateIds) {
- persistentIds.delete(id);
- }
- if (duplicateIds.size) {
- console.warn(
- "[Warning] duplicate ids found during morph, state loss within these elements is possible:",
- Array.from(duplicateIds),
- );
- }
- return persistentIds;
- }
-
- return createMorphContext;
- })();
-
- //=============================================================================
- // HTML Normalization Functions
- //=============================================================================
- const { normalizeElement, normalizeParent } = (function () {
- /** @type {WeakSet} */
- const generatedByIdiomorph = new WeakSet();
-
- /**
- *
- * @param {Node} content
- * @returns {Element}
- */
- function normalizeElement(content) {
- if (is.document(content)) {
- return content.documentElement;
- } else {
- // a Text or Comment node is not an Element, but morphOuterHTML only ever reads Node members off it
- return /** @type {Element} */ (content);
- }
- }
-
- /**
- *
- * @param {null | string | Node | HTMLCollection | Node[]} newContent
- * @returns {Element}
- */
- function normalizeParent(newContent) {
- if (newContent == null) {
- return document.createElement("div"); // dummy parent element
- } else if (typeof newContent === "string") {
- return normalizeParent(parseContent(newContent));
- } else if (
- generatedByIdiomorph.has(/** @type {Element} */ (newContent))
- ) {
- // the template tag created by idiomorph parsing can serve as a dummy parent
- return /** @type {Element} */ (newContent);
- } else if (is.node(newContent)) {
- 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 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");
- dummyParent.append(newContent);
- return dummyParent;
- }
- } else {
- // all nodes in the array or HTMLElement collection are consolidated under
- // a single dummy parent element
- const dummyParent = document.createElement("div");
- for (const elt of [...newContent]) {
- dummyParent.append(elt);
- }
- return dummyParent;
- }
- }
-
- /**
- * 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)
- */
- 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 (is.element(node)) {
- 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;
- }
- }
-
- /**
- *
- * @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(
- /