@hotwired/turbo 8.0.11 → 8.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  /*!
2
- Turbo 8.0.11
3
- Copyright © 2024 37signals LLC
2
+ Turbo 8.0.13
3
+ Copyright © 2025 37signals LLC
4
4
  */
5
5
  (function (global, factory) {
6
6
  typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
@@ -2032,838 +2032,1311 @@ Copyright © 2024 37signals LLC
2032
2032
  }
2033
2033
  }
2034
2034
 
2035
+ /**
2036
+ * @typedef {object} ConfigHead
2037
+ *
2038
+ * @property {'merge' | 'append' | 'morph' | 'none'} [style]
2039
+ * @property {boolean} [block]
2040
+ * @property {boolean} [ignore]
2041
+ * @property {function(Element): boolean} [shouldPreserve]
2042
+ * @property {function(Element): boolean} [shouldReAppend]
2043
+ * @property {function(Element): boolean} [shouldRemove]
2044
+ * @property {function(Element, {added: Node[], kept: Element[], removed: Element[]}): void} [afterHeadMorphed]
2045
+ */
2046
+
2047
+ /**
2048
+ * @typedef {object} ConfigCallbacks
2049
+ *
2050
+ * @property {function(Node): boolean} [beforeNodeAdded]
2051
+ * @property {function(Node): void} [afterNodeAdded]
2052
+ * @property {function(Element, Node): boolean} [beforeNodeMorphed]
2053
+ * @property {function(Element, Node): void} [afterNodeMorphed]
2054
+ * @property {function(Element): boolean} [beforeNodeRemoved]
2055
+ * @property {function(Element): void} [afterNodeRemoved]
2056
+ * @property {function(string, Element, "update" | "remove"): boolean} [beforeAttributeUpdated]
2057
+ */
2058
+
2059
+ /**
2060
+ * @typedef {object} Config
2061
+ *
2062
+ * @property {'outerHTML' | 'innerHTML'} [morphStyle]
2063
+ * @property {boolean} [ignoreActive]
2064
+ * @property {boolean} [ignoreActiveValue]
2065
+ * @property {boolean} [restoreFocus]
2066
+ * @property {ConfigCallbacks} [callbacks]
2067
+ * @property {ConfigHead} [head]
2068
+ */
2069
+
2070
+ /**
2071
+ * @typedef {function} NoOp
2072
+ *
2073
+ * @returns {void}
2074
+ */
2075
+
2076
+ /**
2077
+ * @typedef {object} ConfigHeadInternal
2078
+ *
2079
+ * @property {'merge' | 'append' | 'morph' | 'none'} style
2080
+ * @property {boolean} [block]
2081
+ * @property {boolean} [ignore]
2082
+ * @property {(function(Element): boolean) | NoOp} shouldPreserve
2083
+ * @property {(function(Element): boolean) | NoOp} shouldReAppend
2084
+ * @property {(function(Element): boolean) | NoOp} shouldRemove
2085
+ * @property {(function(Element, {added: Node[], kept: Element[], removed: Element[]}): void) | NoOp} afterHeadMorphed
2086
+ */
2087
+
2088
+ /**
2089
+ * @typedef {object} ConfigCallbacksInternal
2090
+ *
2091
+ * @property {(function(Node): boolean) | NoOp} beforeNodeAdded
2092
+ * @property {(function(Node): void) | NoOp} afterNodeAdded
2093
+ * @property {(function(Node, Node): boolean) | NoOp} beforeNodeMorphed
2094
+ * @property {(function(Node, Node): void) | NoOp} afterNodeMorphed
2095
+ * @property {(function(Node): boolean) | NoOp} beforeNodeRemoved
2096
+ * @property {(function(Node): void) | NoOp} afterNodeRemoved
2097
+ * @property {(function(string, Element, "update" | "remove"): boolean) | NoOp} beforeAttributeUpdated
2098
+ */
2099
+
2100
+ /**
2101
+ * @typedef {object} ConfigInternal
2102
+ *
2103
+ * @property {'outerHTML' | 'innerHTML'} morphStyle
2104
+ * @property {boolean} [ignoreActive]
2105
+ * @property {boolean} [ignoreActiveValue]
2106
+ * @property {boolean} [restoreFocus]
2107
+ * @property {ConfigCallbacksInternal} callbacks
2108
+ * @property {ConfigHeadInternal} head
2109
+ */
2110
+
2111
+ /**
2112
+ * @typedef {Object} IdSets
2113
+ * @property {Set<string>} persistentIds
2114
+ * @property {Map<Node, Set<string>>} idMap
2115
+ */
2116
+
2117
+ /**
2118
+ * @typedef {Function} Morph
2119
+ *
2120
+ * @param {Element | Document} oldNode
2121
+ * @param {Element | Node | HTMLCollection | Node[] | string | null} newContent
2122
+ * @param {Config} [config]
2123
+ * @returns {undefined | Node[]}
2124
+ */
2125
+
2035
2126
  // base IIFE to define idiomorph
2127
+ /**
2128
+ *
2129
+ * @type {{defaults: ConfigInternal, morph: Morph}}
2130
+ */
2036
2131
  var Idiomorph = (function () {
2037
2132
 
2038
- //=============================================================================
2039
- // AND NOW IT BEGINS...
2040
- //=============================================================================
2041
- let EMPTY_SET = new Set();
2042
-
2043
- // default configuration values, updatable by users now
2044
- let defaults = {
2045
- morphStyle: "outerHTML",
2046
- callbacks : {
2047
- beforeNodeAdded: noOp,
2048
- afterNodeAdded: noOp,
2049
- beforeNodeMorphed: noOp,
2050
- afterNodeMorphed: noOp,
2051
- beforeNodeRemoved: noOp,
2052
- afterNodeRemoved: noOp,
2053
- beforeAttributeUpdated: noOp,
2054
-
2055
- },
2056
- head: {
2057
- style: 'merge',
2058
- shouldPreserve: function (elt) {
2059
- return elt.getAttribute("im-preserve") === "true";
2060
- },
2061
- shouldReAppend: function (elt) {
2062
- return elt.getAttribute("im-re-append") === "true";
2063
- },
2064
- shouldRemove: noOp,
2065
- afterHeadMorphed: noOp,
2066
- }
2067
- };
2133
+ /**
2134
+ * @typedef {object} MorphContext
2135
+ *
2136
+ * @property {Element} target
2137
+ * @property {Element} newContent
2138
+ * @property {ConfigInternal} config
2139
+ * @property {ConfigInternal['morphStyle']} morphStyle
2140
+ * @property {ConfigInternal['ignoreActive']} ignoreActive
2141
+ * @property {ConfigInternal['ignoreActiveValue']} ignoreActiveValue
2142
+ * @property {ConfigInternal['restoreFocus']} restoreFocus
2143
+ * @property {Map<Node, Set<string>>} idMap
2144
+ * @property {Set<string>} persistentIds
2145
+ * @property {ConfigInternal['callbacks']} callbacks
2146
+ * @property {ConfigInternal['head']} head
2147
+ * @property {HTMLDivElement} pantry
2148
+ */
2068
2149
 
2069
- //=============================================================================
2070
- // Core Morphing Algorithm - morph, morphNormalizedContent, morphOldNodeTo, morphChildren
2071
- //=============================================================================
2072
- function morph(oldNode, newContent, config = {}) {
2150
+ //=============================================================================
2151
+ // AND NOW IT BEGINS...
2152
+ //=============================================================================
2073
2153
 
2074
- if (oldNode instanceof Document) {
2075
- oldNode = oldNode.documentElement;
2076
- }
2154
+ const noOp = () => {};
2155
+ /**
2156
+ * Default configuration values, updatable by users now
2157
+ * @type {ConfigInternal}
2158
+ */
2159
+ const defaults = {
2160
+ morphStyle: "outerHTML",
2161
+ callbacks: {
2162
+ beforeNodeAdded: noOp,
2163
+ afterNodeAdded: noOp,
2164
+ beforeNodeMorphed: noOp,
2165
+ afterNodeMorphed: noOp,
2166
+ beforeNodeRemoved: noOp,
2167
+ afterNodeRemoved: noOp,
2168
+ beforeAttributeUpdated: noOp,
2169
+ },
2170
+ head: {
2171
+ style: "merge",
2172
+ shouldPreserve: (elt) => elt.getAttribute("im-preserve") === "true",
2173
+ shouldReAppend: (elt) => elt.getAttribute("im-re-append") === "true",
2174
+ shouldRemove: noOp,
2175
+ afterHeadMorphed: noOp,
2176
+ },
2177
+ restoreFocus: true,
2178
+ };
2077
2179
 
2078
- if (typeof newContent === 'string') {
2079
- newContent = parseContent(newContent);
2080
- }
2180
+ /**
2181
+ * Core idiomorph function for morphing one DOM tree to another
2182
+ *
2183
+ * @param {Element | Document} oldNode
2184
+ * @param {Element | Node | HTMLCollection | Node[] | string | null} newContent
2185
+ * @param {Config} [config]
2186
+ * @returns {Promise<Node[]> | Node[]}
2187
+ */
2188
+ function morph(oldNode, newContent, config = {}) {
2189
+ oldNode = normalizeElement(oldNode);
2190
+ const newNode = normalizeParent(newContent);
2191
+ const ctx = createMorphContext(oldNode, newNode, config);
2192
+
2193
+ const morphedNodes = saveAndRestoreFocus(ctx, () => {
2194
+ return withHeadBlocking(
2195
+ ctx,
2196
+ oldNode,
2197
+ newNode,
2198
+ /** @param {MorphContext} ctx */ (ctx) => {
2199
+ if (ctx.morphStyle === "innerHTML") {
2200
+ morphChildren(ctx, oldNode, newNode);
2201
+ return Array.from(oldNode.childNodes);
2202
+ } else {
2203
+ return morphOuterHTML(ctx, oldNode, newNode);
2204
+ }
2205
+ },
2206
+ );
2207
+ });
2081
2208
 
2082
- let normalizedContent = normalizeContent(newContent);
2209
+ ctx.pantry.remove();
2210
+ return morphedNodes;
2211
+ }
2083
2212
 
2084
- let ctx = createMorphContext(oldNode, normalizedContent, config);
2213
+ /**
2214
+ * Morph just the outerHTML of the oldNode to the newContent
2215
+ * We have to be careful because the oldNode could have siblings which need to be untouched
2216
+ * @param {MorphContext} ctx
2217
+ * @param {Element} oldNode
2218
+ * @param {Element} newNode
2219
+ * @returns {Node[]}
2220
+ */
2221
+ function morphOuterHTML(ctx, oldNode, newNode) {
2222
+ const oldParent = normalizeParent(oldNode);
2223
+
2224
+ // basis for calulating which nodes were morphed
2225
+ // since there may be unmorphed sibling nodes
2226
+ let childNodes = Array.from(oldParent.childNodes);
2227
+ const index = childNodes.indexOf(oldNode);
2228
+ // how many elements are to the right of the oldNode
2229
+ const rightMargin = childNodes.length - (index + 1);
2230
+
2231
+ morphChildren(
2232
+ ctx,
2233
+ oldParent,
2234
+ newNode,
2235
+ // these two optional params are the secret sauce
2236
+ oldNode, // start point for iteration
2237
+ oldNode.nextSibling, // end point for iteration
2238
+ );
2085
2239
 
2086
- return morphNormalizedContent(oldNode, normalizedContent, ctx);
2087
- }
2240
+ // return just the morphed nodes
2241
+ childNodes = Array.from(oldParent.childNodes);
2242
+ return childNodes.slice(index, childNodes.length - rightMargin);
2243
+ }
2088
2244
 
2089
- function morphNormalizedContent(oldNode, normalizedNewContent, ctx) {
2090
- if (ctx.head.block) {
2091
- let oldHead = oldNode.querySelector('head');
2092
- let newHead = normalizedNewContent.querySelector('head');
2093
- if (oldHead && newHead) {
2094
- let promises = handleHeadElement(newHead, oldHead, ctx);
2095
- // when head promises resolve, call morph again, ignoring the head tag
2096
- Promise.all(promises).then(function () {
2097
- morphNormalizedContent(oldNode, normalizedNewContent, Object.assign(ctx, {
2098
- head: {
2099
- block: false,
2100
- ignore: true
2101
- }
2102
- }));
2103
- });
2104
- return;
2105
- }
2106
- }
2245
+ /**
2246
+ * @param {MorphContext} ctx
2247
+ * @param {Function} fn
2248
+ * @returns {Promise<Node[]> | Node[]}
2249
+ */
2250
+ function saveAndRestoreFocus(ctx, fn) {
2251
+ if (!ctx.config.restoreFocus) return fn();
2252
+ let activeElement =
2253
+ /** @type {HTMLInputElement|HTMLTextAreaElement|null} */ (
2254
+ document.activeElement
2255
+ );
2107
2256
 
2108
- if (ctx.morphStyle === "innerHTML") {
2109
-
2110
- // innerHTML, so we are only updating the children
2111
- morphChildren(normalizedNewContent, oldNode, ctx);
2112
- return oldNode.children;
2113
-
2114
- } else if (ctx.morphStyle === "outerHTML" || ctx.morphStyle == null) {
2115
- // otherwise find the best element match in the new content, morph that, and merge its siblings
2116
- // into either side of the best match
2117
- let bestMatch = findBestNodeMatch(normalizedNewContent, oldNode, ctx);
2118
-
2119
- // stash the siblings that will need to be inserted on either side of the best match
2120
- let previousSibling = bestMatch?.previousSibling;
2121
- let nextSibling = bestMatch?.nextSibling;
2122
-
2123
- // morph it
2124
- let morphedNode = morphOldNodeTo(oldNode, bestMatch, ctx);
2125
-
2126
- if (bestMatch) {
2127
- // if there was a best match, merge the siblings in too and return the
2128
- // whole bunch
2129
- return insertSiblings(previousSibling, morphedNode, nextSibling);
2130
- } else {
2131
- // otherwise nothing was added to the DOM
2132
- return []
2133
- }
2134
- } else {
2135
- throw "Do not understand how to morph style " + ctx.morphStyle;
2257
+ // don't bother if the active element is not an input or textarea
2258
+ if (
2259
+ !(
2260
+ activeElement instanceof HTMLInputElement ||
2261
+ activeElement instanceof HTMLTextAreaElement
2262
+ )
2263
+ ) {
2264
+ return fn();
2265
+ }
2266
+
2267
+ const { id: activeElementId, selectionStart, selectionEnd } = activeElement;
2268
+
2269
+ const results = fn();
2270
+
2271
+ if (activeElementId && activeElementId !== document.activeElement?.id) {
2272
+ activeElement = ctx.target.querySelector(`#${activeElementId}`);
2273
+ activeElement?.focus();
2274
+ }
2275
+ if (activeElement && !activeElement.selectionEnd && selectionEnd) {
2276
+ activeElement.setSelectionRange(selectionStart, selectionEnd);
2277
+ }
2278
+
2279
+ return results;
2280
+ }
2281
+
2282
+ const morphChildren = (function () {
2283
+ /**
2284
+ * This is the core algorithm for matching up children. The idea is to use id sets to try to match up
2285
+ * nodes as faithfully as possible. We greedily match, which allows us to keep the algorithm fast, but
2286
+ * by using id sets, we are able to better match up with content deeper in the DOM.
2287
+ *
2288
+ * Basic algorithm:
2289
+ * - for each node in the new content:
2290
+ * - search self and siblings for an id set match, falling back to a soft match
2291
+ * - if match found
2292
+ * - remove any nodes up to the match:
2293
+ * - pantry persistent nodes
2294
+ * - delete the rest
2295
+ * - morph the match
2296
+ * - elsif no match found, and node is persistent
2297
+ * - find its match by querying the old root (future) and pantry (past)
2298
+ * - move it and its children here
2299
+ * - morph it
2300
+ * - else
2301
+ * - create a new node from scratch as a last result
2302
+ *
2303
+ * @param {MorphContext} ctx the merge context
2304
+ * @param {Element} oldParent the old content that we are merging the new content into
2305
+ * @param {Element} newParent the parent element of the new content
2306
+ * @param {Node|null} [insertionPoint] the point in the DOM we start morphing at (defaults to first child)
2307
+ * @param {Node|null} [endPoint] the point in the DOM we stop morphing at (defaults to after last child)
2308
+ */
2309
+ function morphChildren(
2310
+ ctx,
2311
+ oldParent,
2312
+ newParent,
2313
+ insertionPoint = null,
2314
+ endPoint = null,
2315
+ ) {
2316
+ // normalize
2317
+ if (
2318
+ oldParent instanceof HTMLTemplateElement &&
2319
+ newParent instanceof HTMLTemplateElement
2320
+ ) {
2321
+ // @ts-ignore we can pretend the DocumentFragment is an Element
2322
+ oldParent = oldParent.content;
2323
+ // @ts-ignore ditto
2324
+ newParent = newParent.content;
2325
+ }
2326
+ insertionPoint ||= oldParent.firstChild;
2327
+
2328
+ // run through all the new content
2329
+ for (const newChild of newParent.childNodes) {
2330
+ // once we reach the end of the old parent content skip to the end and insert the rest
2331
+ if (insertionPoint && insertionPoint != endPoint) {
2332
+ const bestMatch = findBestMatch(
2333
+ ctx,
2334
+ newChild,
2335
+ insertionPoint,
2336
+ endPoint,
2337
+ );
2338
+ if (bestMatch) {
2339
+ // if the node to morph is not at the insertion point then remove/move up to it
2340
+ if (bestMatch !== insertionPoint) {
2341
+ removeNodesBetween(ctx, insertionPoint, bestMatch);
2136
2342
  }
2343
+ morphNode(bestMatch, newChild, ctx);
2344
+ insertionPoint = bestMatch.nextSibling;
2345
+ continue;
2346
+ }
2137
2347
  }
2138
2348
 
2139
-
2140
- /**
2141
- * @param possibleActiveElement
2142
- * @param ctx
2143
- * @returns {boolean}
2144
- */
2145
- function ignoreValueOfActiveElement(possibleActiveElement, ctx) {
2146
- return ctx.ignoreActiveValue && possibleActiveElement === document.activeElement && possibleActiveElement !== document.body;
2349
+ // if the matching node is elsewhere in the original content
2350
+ if (newChild instanceof Element && ctx.persistentIds.has(newChild.id)) {
2351
+ // move it and all its children here and morph
2352
+ const movedChild = moveBeforeById(
2353
+ oldParent,
2354
+ newChild.id,
2355
+ insertionPoint,
2356
+ ctx,
2357
+ );
2358
+ morphNode(movedChild, newChild, ctx);
2359
+ insertionPoint = movedChild.nextSibling;
2360
+ continue;
2147
2361
  }
2148
2362
 
2149
- /**
2150
- * @param oldNode root node to merge content into
2151
- * @param newContent new content to merge
2152
- * @param ctx the merge context
2153
- * @returns {Element} the element that ended up in the DOM
2154
- */
2155
- function morphOldNodeTo(oldNode, newContent, ctx) {
2156
- if (ctx.ignoreActive && oldNode === document.activeElement) ; else if (newContent == null) {
2157
- if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode;
2158
-
2159
- oldNode.remove();
2160
- ctx.callbacks.afterNodeRemoved(oldNode);
2161
- return null;
2162
- } else if (!isSoftMatch(oldNode, newContent)) {
2163
- if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode;
2164
- if (ctx.callbacks.beforeNodeAdded(newContent) === false) return oldNode;
2165
-
2166
- oldNode.parentElement.replaceChild(newContent, oldNode);
2167
- ctx.callbacks.afterNodeAdded(newContent);
2168
- ctx.callbacks.afterNodeRemoved(oldNode);
2169
- return newContent;
2170
- } else {
2171
- if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) return oldNode;
2172
-
2173
- if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) ; else if (oldNode instanceof HTMLHeadElement && ctx.head.style !== "morph") {
2174
- handleHeadElement(newContent, oldNode, ctx);
2175
- } else {
2176
- syncNodeFrom(newContent, oldNode, ctx);
2177
- if (!ignoreValueOfActiveElement(oldNode, ctx)) {
2178
- morphChildren(newContent, oldNode, ctx);
2179
- }
2180
- }
2181
- ctx.callbacks.afterNodeMorphed(oldNode, newContent);
2182
- return oldNode;
2183
- }
2363
+ // last resort: insert the new node from scratch
2364
+ const insertedNode = createNode(
2365
+ oldParent,
2366
+ newChild,
2367
+ insertionPoint,
2368
+ ctx,
2369
+ );
2370
+ // could be null if beforeNodeAdded prevented insertion
2371
+ if (insertedNode) {
2372
+ insertionPoint = insertedNode.nextSibling;
2184
2373
  }
2374
+ }
2185
2375
 
2186
- /**
2187
- * This is the core algorithm for matching up children. The idea is to use id sets to try to match up
2188
- * nodes as faithfully as possible. We greedily match, which allows us to keep the algorithm fast, but
2189
- * by using id sets, we are able to better match up with content deeper in the DOM.
2190
- *
2191
- * Basic algorithm is, for each node in the new content:
2192
- *
2193
- * - if we have reached the end of the old parent, append the new content
2194
- * - if the new content has an id set match with the current insertion point, morph
2195
- * - search for an id set match
2196
- * - if id set match found, morph
2197
- * - otherwise search for a "soft" match
2198
- * - if a soft match is found, morph
2199
- * - otherwise, prepend the new node before the current insertion point
2200
- *
2201
- * The two search algorithms terminate if competing node matches appear to outweigh what can be achieved
2202
- * with the current node. See findIdSetMatch() and findSoftMatch() for details.
2203
- *
2204
- * @param {Element} newParent the parent element of the new content
2205
- * @param {Element } oldParent the old content that we are merging the new content into
2206
- * @param ctx the merge context
2207
- */
2208
- function morphChildren(newParent, oldParent, ctx) {
2209
-
2210
- let nextNewChild = newParent.firstChild;
2211
- let insertionPoint = oldParent.firstChild;
2212
- let newChild;
2213
-
2214
- // run through all the new content
2215
- while (nextNewChild) {
2216
-
2217
- newChild = nextNewChild;
2218
- nextNewChild = newChild.nextSibling;
2219
-
2220
- // if we are at the end of the exiting parent's children, just append
2221
- if (insertionPoint == null) {
2222
- if (ctx.callbacks.beforeNodeAdded(newChild) === false) return;
2223
-
2224
- oldParent.appendChild(newChild);
2225
- ctx.callbacks.afterNodeAdded(newChild);
2226
- removeIdsFromConsideration(ctx, newChild);
2227
- continue;
2228
- }
2229
-
2230
- // if the current node has an id set match then morph
2231
- if (isIdSetMatch(newChild, insertionPoint, ctx)) {
2232
- morphOldNodeTo(insertionPoint, newChild, ctx);
2233
- insertionPoint = insertionPoint.nextSibling;
2234
- removeIdsFromConsideration(ctx, newChild);
2235
- continue;
2236
- }
2237
-
2238
- // otherwise search forward in the existing old children for an id set match
2239
- let idSetMatch = findIdSetMatch(newParent, oldParent, newChild, insertionPoint, ctx);
2240
-
2241
- // if we found a potential match, remove the nodes until that point and morph
2242
- if (idSetMatch) {
2243
- insertionPoint = removeNodesBetween(insertionPoint, idSetMatch, ctx);
2244
- morphOldNodeTo(idSetMatch, newChild, ctx);
2245
- removeIdsFromConsideration(ctx, newChild);
2246
- continue;
2247
- }
2248
-
2249
- // no id set match found, so scan forward for a soft match for the current node
2250
- let softMatch = findSoftMatch(newParent, oldParent, newChild, insertionPoint, ctx);
2251
-
2252
- // if we found a soft match for the current node, morph
2253
- if (softMatch) {
2254
- insertionPoint = removeNodesBetween(insertionPoint, softMatch, ctx);
2255
- morphOldNodeTo(softMatch, newChild, ctx);
2256
- removeIdsFromConsideration(ctx, newChild);
2257
- continue;
2258
- }
2259
-
2260
- // abandon all hope of morphing, just insert the new child before the insertion point
2261
- // and move on
2262
- if (ctx.callbacks.beforeNodeAdded(newChild) === false) return;
2263
-
2264
- oldParent.insertBefore(newChild, insertionPoint);
2265
- ctx.callbacks.afterNodeAdded(newChild);
2266
- removeIdsFromConsideration(ctx, newChild);
2267
- }
2376
+ // remove any remaining old nodes that didn't match up with new content
2377
+ while (insertionPoint && insertionPoint != endPoint) {
2378
+ const tempNode = insertionPoint;
2379
+ insertionPoint = insertionPoint.nextSibling;
2380
+ removeNode(ctx, tempNode);
2381
+ }
2382
+ }
2268
2383
 
2269
- // remove any remaining old nodes that didn't match up with new content
2270
- while (insertionPoint !== null) {
2384
+ /**
2385
+ * This performs the action of inserting a new node while handling situations where the node contains
2386
+ * elements with persistent ids and possible state info we can still preserve by moving in and then morphing
2387
+ *
2388
+ * @param {Element} oldParent
2389
+ * @param {Node} newChild
2390
+ * @param {Node|null} insertionPoint
2391
+ * @param {MorphContext} ctx
2392
+ * @returns {Node|null}
2393
+ */
2394
+ function createNode(oldParent, newChild, insertionPoint, ctx) {
2395
+ if (ctx.callbacks.beforeNodeAdded(newChild) === false) return null;
2396
+ if (ctx.idMap.has(newChild)) {
2397
+ // node has children with ids with possible state so create a dummy elt of same type and apply full morph algorithm
2398
+ const newEmptyChild = document.createElement(
2399
+ /** @type {Element} */ (newChild).tagName,
2400
+ );
2401
+ oldParent.insertBefore(newEmptyChild, insertionPoint);
2402
+ morphNode(newEmptyChild, newChild, ctx);
2403
+ ctx.callbacks.afterNodeAdded(newEmptyChild);
2404
+ return newEmptyChild;
2405
+ } else {
2406
+ // optimisation: no id state to preserve so we can just insert a clone of the newChild and its descendants
2407
+ const newClonedChild = document.importNode(newChild, true); // importNode to not mutate newParent
2408
+ oldParent.insertBefore(newClonedChild, insertionPoint);
2409
+ ctx.callbacks.afterNodeAdded(newClonedChild);
2410
+ return newClonedChild;
2411
+ }
2412
+ }
2271
2413
 
2272
- let tempNode = insertionPoint;
2273
- insertionPoint = insertionPoint.nextSibling;
2274
- removeNode(tempNode, ctx);
2414
+ //=============================================================================
2415
+ // Matching Functions
2416
+ //=============================================================================
2417
+ const findBestMatch = (function () {
2418
+ /**
2419
+ * Scans forward from the startPoint to the endPoint looking for a match
2420
+ * for the node. It looks for an id set match first, then a soft match.
2421
+ * We abort softmatching if we find two future soft matches, to reduce churn.
2422
+ * @param {Node} node
2423
+ * @param {MorphContext} ctx
2424
+ * @param {Node | null} startPoint
2425
+ * @param {Node | null} endPoint
2426
+ * @returns {Node | null}
2427
+ */
2428
+ function findBestMatch(ctx, node, startPoint, endPoint) {
2429
+ let softMatch = null;
2430
+ let nextSibling = node.nextSibling;
2431
+ let siblingSoftMatchCount = 0;
2432
+
2433
+ let cursor = startPoint;
2434
+ while (cursor && cursor != endPoint) {
2435
+ // soft matching is a prerequisite for id set matching
2436
+ if (isSoftMatch(cursor, node)) {
2437
+ if (isIdSetMatch(ctx, cursor, node)) {
2438
+ return cursor; // found an id set match, we're done!
2275
2439
  }
2276
- }
2277
2440
 
2278
- //=============================================================================
2279
- // Attribute Syncing Code
2280
- //=============================================================================
2281
-
2282
- /**
2283
- * @param attr {String} the attribute to be mutated
2284
- * @param to {Element} the element that is going to be updated
2285
- * @param updateType {("update"|"remove")}
2286
- * @param ctx the merge context
2287
- * @returns {boolean} true if the attribute should be ignored, false otherwise
2288
- */
2289
- function ignoreAttribute(attr, to, updateType, ctx) {
2290
- if(attr === 'value' && ctx.ignoreActiveValue && to === document.activeElement){
2291
- return true;
2441
+ // we haven't yet saved a soft match fallback
2442
+ if (softMatch === null) {
2443
+ // the current soft match will hard match something else in the future, leave it
2444
+ if (!ctx.idMap.has(cursor)) {
2445
+ // save this as the fallback if we get through the loop without finding a hard match
2446
+ softMatch = cursor;
2447
+ }
2292
2448
  }
2293
- return ctx.callbacks.beforeAttributeUpdated(attr, to, updateType) === false;
2294
- }
2295
-
2296
- /**
2297
- * syncs a given node with another node, copying over all attributes and
2298
- * inner element state from the 'from' node to the 'to' node
2299
- *
2300
- * @param {Element} from the element to copy attributes & state from
2301
- * @param {Element} to the element to copy attributes & state to
2302
- * @param ctx the merge context
2303
- */
2304
- function syncNodeFrom(from, to, ctx) {
2305
- let type = from.nodeType;
2306
-
2307
- // if is an element type, sync the attributes from the
2308
- // new node into the new node
2309
- if (type === 1 /* element type */) {
2310
- const fromAttributes = from.attributes;
2311
- const toAttributes = to.attributes;
2312
- for (const fromAttribute of fromAttributes) {
2313
- if (ignoreAttribute(fromAttribute.name, to, 'update', ctx)) {
2314
- continue;
2315
- }
2316
- if (to.getAttribute(fromAttribute.name) !== fromAttribute.value) {
2317
- to.setAttribute(fromAttribute.name, fromAttribute.value);
2318
- }
2319
- }
2320
- // iterate backwards to avoid skipping over items when a delete occurs
2321
- for (let i = toAttributes.length - 1; 0 <= i; i--) {
2322
- const toAttribute = toAttributes[i];
2323
- if (ignoreAttribute(toAttribute.name, to, 'remove', ctx)) {
2324
- continue;
2325
- }
2326
- if (!from.hasAttribute(toAttribute.name)) {
2327
- to.removeAttribute(toAttribute.name);
2328
- }
2329
- }
2449
+ }
2450
+ if (
2451
+ softMatch === null &&
2452
+ nextSibling &&
2453
+ isSoftMatch(cursor, nextSibling)
2454
+ ) {
2455
+ // The next new node has a soft match with this node, so
2456
+ // increment the count of future soft matches
2457
+ siblingSoftMatchCount++;
2458
+ nextSibling = nextSibling.nextSibling;
2459
+
2460
+ // If there are two future soft matches, block soft matching for this node to allow
2461
+ // future siblings to soft match. This is to reduce churn in the DOM when an element
2462
+ // is prepended.
2463
+ if (siblingSoftMatchCount >= 2) {
2464
+ softMatch = undefined;
2330
2465
  }
2466
+ }
2331
2467
 
2332
- // sync text nodes
2333
- if (type === 8 /* comment */ || type === 3 /* text */) {
2334
- if (to.nodeValue !== from.nodeValue) {
2335
- to.nodeValue = from.nodeValue;
2336
- }
2337
- }
2468
+ // if the current node contains active element, stop looking for better future matches,
2469
+ // because if one is found, this node will be moved to the pantry, reparenting it and thus losing focus
2470
+ if (cursor.contains(document.activeElement)) break;
2338
2471
 
2339
- if (!ignoreValueOfActiveElement(to, ctx)) {
2340
- // sync input values
2341
- syncInputValue(from, to, ctx);
2342
- }
2472
+ cursor = cursor.nextSibling;
2343
2473
  }
2344
2474
 
2345
- /**
2346
- * @param from {Element} element to sync the value from
2347
- * @param to {Element} element to sync the value to
2348
- * @param attributeName {String} the attribute name
2349
- * @param ctx the merge context
2350
- */
2351
- function syncBooleanAttribute(from, to, attributeName, ctx) {
2352
- if (from[attributeName] !== to[attributeName]) {
2353
- let ignoreUpdate = ignoreAttribute(attributeName, to, 'update', ctx);
2354
- if (!ignoreUpdate) {
2355
- to[attributeName] = from[attributeName];
2356
- }
2357
- if (from[attributeName]) {
2358
- if (!ignoreUpdate) {
2359
- to.setAttribute(attributeName, from[attributeName]);
2360
- }
2361
- } else {
2362
- if (!ignoreAttribute(attributeName, to, 'remove', ctx)) {
2363
- to.removeAttribute(attributeName);
2364
- }
2365
- }
2366
- }
2367
- }
2475
+ return softMatch || null;
2476
+ }
2368
2477
 
2369
- /**
2370
- * NB: many bothans died to bring us information:
2371
- *
2372
- * https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js
2373
- * https://github.com/choojs/nanomorph/blob/master/lib/morph.jsL113
2374
- *
2375
- * @param from {Element} the element to sync the input value from
2376
- * @param to {Element} the element to sync the input value to
2377
- * @param ctx the merge context
2378
- */
2379
- function syncInputValue(from, to, ctx) {
2380
- if (from instanceof HTMLInputElement &&
2381
- to instanceof HTMLInputElement &&
2382
- from.type !== 'file') {
2383
-
2384
- let fromValue = from.value;
2385
- let toValue = to.value;
2386
-
2387
- // sync boolean attributes
2388
- syncBooleanAttribute(from, to, 'checked', ctx);
2389
- syncBooleanAttribute(from, to, 'disabled', ctx);
2390
-
2391
- if (!from.hasAttribute('value')) {
2392
- if (!ignoreAttribute('value', to, 'remove', ctx)) {
2393
- to.value = '';
2394
- to.removeAttribute('value');
2395
- }
2396
- } else if (fromValue !== toValue) {
2397
- if (!ignoreAttribute('value', to, 'update', ctx)) {
2398
- to.setAttribute('value', fromValue);
2399
- to.value = fromValue;
2400
- }
2401
- }
2402
- } else if (from instanceof HTMLOptionElement) {
2403
- syncBooleanAttribute(from, to, 'selected', ctx);
2404
- } else if (from instanceof HTMLTextAreaElement && to instanceof HTMLTextAreaElement) {
2405
- let fromValue = from.value;
2406
- let toValue = to.value;
2407
- if (ignoreAttribute('value', to, 'update', ctx)) {
2408
- return;
2409
- }
2410
- if (fromValue !== toValue) {
2411
- to.value = fromValue;
2412
- }
2413
- if (to.firstChild && to.firstChild.nodeValue !== fromValue) {
2414
- to.firstChild.nodeValue = fromValue;
2415
- }
2416
- }
2478
+ /**
2479
+ *
2480
+ * @param {MorphContext} ctx
2481
+ * @param {Node} oldNode
2482
+ * @param {Node} newNode
2483
+ * @returns {boolean}
2484
+ */
2485
+ function isIdSetMatch(ctx, oldNode, newNode) {
2486
+ let oldSet = ctx.idMap.get(oldNode);
2487
+ let newSet = ctx.idMap.get(newNode);
2488
+
2489
+ if (!newSet || !oldSet) return false;
2490
+
2491
+ for (const id of oldSet) {
2492
+ // a potential match is an id in the new and old nodes that
2493
+ // has not already been merged into the DOM
2494
+ // But the newNode content we call this on has not been
2495
+ // merged yet and we don't allow duplicate IDs so it is simple
2496
+ if (newSet.has(id)) {
2497
+ return true;
2498
+ }
2417
2499
  }
2500
+ return false;
2501
+ }
2418
2502
 
2419
- //=============================================================================
2420
- // the HEAD tag can be handled specially, either w/ a 'merge' or 'append' style
2421
- //=============================================================================
2422
- function handleHeadElement(newHeadTag, currentHead, ctx) {
2503
+ /**
2504
+ *
2505
+ * @param {Node} oldNode
2506
+ * @param {Node} newNode
2507
+ * @returns {boolean}
2508
+ */
2509
+ function isSoftMatch(oldNode, newNode) {
2510
+ // ok to cast: if one is not element, `id` and `tagName` will be undefined and we'll just compare that.
2511
+ const oldElt = /** @type {Element} */ (oldNode);
2512
+ const newElt = /** @type {Element} */ (newNode);
2513
+
2514
+ return (
2515
+ oldElt.nodeType === newElt.nodeType &&
2516
+ oldElt.tagName === newElt.tagName &&
2517
+ // If oldElt has an `id` with possible state and it doesn't match newElt.id then avoid morphing.
2518
+ // We'll still match an anonymous node with an IDed newElt, though, because if it got this far,
2519
+ // its not persistent, and new nodes can't have any hidden state.
2520
+ (!oldElt.id || oldElt.id === newElt.id)
2521
+ );
2522
+ }
2423
2523
 
2424
- let added = [];
2425
- let removed = [];
2426
- let preserved = [];
2427
- let nodesToAppend = [];
2524
+ return findBestMatch;
2525
+ })();
2428
2526
 
2429
- let headMergeStyle = ctx.head.style;
2527
+ //=============================================================================
2528
+ // DOM Manipulation Functions
2529
+ //=============================================================================
2530
+
2531
+ /**
2532
+ * Gets rid of an unwanted DOM node; strategy depends on nature of its reuse:
2533
+ * - Persistent nodes will be moved to the pantry for later reuse
2534
+ * - Other nodes will have their hooks called, and then are removed
2535
+ * @param {MorphContext} ctx
2536
+ * @param {Node} node
2537
+ */
2538
+ function removeNode(ctx, node) {
2539
+ // are we going to id set match this later?
2540
+ if (ctx.idMap.has(node)) {
2541
+ // skip callbacks and move to pantry
2542
+ moveBefore(ctx.pantry, node, null);
2543
+ } else {
2544
+ // remove for realsies
2545
+ if (ctx.callbacks.beforeNodeRemoved(node) === false) return;
2546
+ node.parentNode?.removeChild(node);
2547
+ ctx.callbacks.afterNodeRemoved(node);
2548
+ }
2549
+ }
2430
2550
 
2431
- // put all new head elements into a Map, by their outerHTML
2432
- let srcToNewHeadNodes = new Map();
2433
- for (const newHeadChild of newHeadTag.children) {
2434
- srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild);
2435
- }
2551
+ /**
2552
+ * Remove nodes between the start and end nodes
2553
+ * @param {MorphContext} ctx
2554
+ * @param {Node} startInclusive
2555
+ * @param {Node} endExclusive
2556
+ * @returns {Node|null}
2557
+ */
2558
+ function removeNodesBetween(ctx, startInclusive, endExclusive) {
2559
+ /** @type {Node | null} */
2560
+ let cursor = startInclusive;
2561
+ // remove nodes until the endExclusive node
2562
+ while (cursor && cursor !== endExclusive) {
2563
+ let tempNode = /** @type {Node} */ (cursor);
2564
+ cursor = cursor.nextSibling;
2565
+ removeNode(ctx, tempNode);
2566
+ }
2567
+ return cursor;
2568
+ }
2569
+
2570
+ /**
2571
+ * Search for an element by id within the document and pantry, and move it using moveBefore.
2572
+ *
2573
+ * @param {Element} parentNode - The parent node to which the element will be moved.
2574
+ * @param {string} id - The ID of the element to be moved.
2575
+ * @param {Node | null} after - The reference node to insert the element before.
2576
+ * If `null`, the element is appended as the last child.
2577
+ * @param {MorphContext} ctx
2578
+ * @returns {Element} The found element
2579
+ */
2580
+ function moveBeforeById(parentNode, id, after, ctx) {
2581
+ const target =
2582
+ /** @type {Element} - will always be found */
2583
+ (
2584
+ ctx.target.querySelector(`#${id}`) ||
2585
+ ctx.pantry.querySelector(`#${id}`)
2586
+ );
2587
+ removeElementFromAncestorsIdMaps(target, ctx);
2588
+ moveBefore(parentNode, target, after);
2589
+ return target;
2590
+ }
2591
+
2592
+ /**
2593
+ * Removes an element from its ancestors' id maps. This is needed when an element is moved from the
2594
+ * "future" via `moveBeforeId`. Otherwise, its erstwhile ancestors could be mistakenly moved to the
2595
+ * pantry rather than being deleted, preventing their removal hooks from being called.
2596
+ *
2597
+ * @param {Element} element - element to remove from its ancestors' id maps
2598
+ * @param {MorphContext} ctx
2599
+ */
2600
+ function removeElementFromAncestorsIdMaps(element, ctx) {
2601
+ const id = element.id;
2602
+ /** @ts-ignore - safe to loop in this way **/
2603
+ while ((element = element.parentNode)) {
2604
+ let idSet = ctx.idMap.get(element);
2605
+ if (idSet) {
2606
+ idSet.delete(id);
2607
+ if (!idSet.size) {
2608
+ ctx.idMap.delete(element);
2609
+ }
2610
+ }
2611
+ }
2612
+ }
2436
2613
 
2437
- // for each elt in the current head
2438
- for (const currentHeadElt of currentHead.children) {
2439
-
2440
- // If the current head element is in the map
2441
- let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML);
2442
- let isReAppended = ctx.head.shouldReAppend(currentHeadElt);
2443
- let isPreserved = ctx.head.shouldPreserve(currentHeadElt);
2444
- if (inNewContent || isPreserved) {
2445
- if (isReAppended) {
2446
- // remove the current version and let the new version replace it and re-execute
2447
- removed.push(currentHeadElt);
2448
- } else {
2449
- // this element already exists and should not be re-appended, so remove it from
2450
- // the new content map, preserving it in the DOM
2451
- srcToNewHeadNodes.delete(currentHeadElt.outerHTML);
2452
- preserved.push(currentHeadElt);
2453
- }
2454
- } else {
2455
- if (headMergeStyle === "append") {
2456
- // we are appending and this existing element is not new content
2457
- // so if and only if it is marked for re-append do we do anything
2458
- if (isReAppended) {
2459
- removed.push(currentHeadElt);
2460
- nodesToAppend.push(currentHeadElt);
2461
- }
2462
- } else {
2463
- // if this is a merge, we remove this content since it is not in the new head
2464
- if (ctx.head.shouldRemove(currentHeadElt) !== false) {
2465
- removed.push(currentHeadElt);
2466
- }
2467
- }
2468
- }
2469
- }
2614
+ /**
2615
+ * Moves an element before another element within the same parent.
2616
+ * Uses the proposed `moveBefore` API if available (and working), otherwise falls back to `insertBefore`.
2617
+ * This is essentialy a forward-compat wrapper.
2618
+ *
2619
+ * @param {Element} parentNode - The parent node containing the after element.
2620
+ * @param {Node} element - The element to be moved.
2621
+ * @param {Node | null} after - The reference node to insert `element` before.
2622
+ * If `null`, `element` is appended as the last child.
2623
+ */
2624
+ function moveBefore(parentNode, element, after) {
2625
+ // @ts-ignore - use proposed moveBefore feature
2626
+ if (parentNode.moveBefore) {
2627
+ try {
2628
+ // @ts-ignore - use proposed moveBefore feature
2629
+ parentNode.moveBefore(element, after);
2630
+ } catch (e) {
2631
+ // fall back to insertBefore as some browsers may fail on moveBefore when trying to move Dom disconnected nodes to pantry
2632
+ parentNode.insertBefore(element, after);
2633
+ }
2634
+ } else {
2635
+ parentNode.insertBefore(element, after);
2636
+ }
2637
+ }
2470
2638
 
2471
- // Push the remaining new head elements in the Map into the
2472
- // nodes to append to the head tag
2473
- nodesToAppend.push(...srcToNewHeadNodes.values());
2474
-
2475
- let promises = [];
2476
- for (const newNode of nodesToAppend) {
2477
- let newElt = document.createRange().createContextualFragment(newNode.outerHTML).firstChild;
2478
- if (ctx.callbacks.beforeNodeAdded(newElt) !== false) {
2479
- if (newElt.href || newElt.src) {
2480
- let resolve = null;
2481
- let promise = new Promise(function (_resolve) {
2482
- resolve = _resolve;
2483
- });
2484
- newElt.addEventListener('load', function () {
2485
- resolve();
2486
- });
2487
- promises.push(promise);
2488
- }
2489
- currentHead.appendChild(newElt);
2490
- ctx.callbacks.afterNodeAdded(newElt);
2491
- added.push(newElt);
2492
- }
2493
- }
2639
+ return morphChildren;
2640
+ })();
2641
+
2642
+ //=============================================================================
2643
+ // Single Node Morphing Code
2644
+ //=============================================================================
2645
+ const morphNode = (function () {
2646
+ /**
2647
+ * @param {Node} oldNode root node to merge content into
2648
+ * @param {Node} newContent new content to merge
2649
+ * @param {MorphContext} ctx the merge context
2650
+ * @returns {Node | null} the element that ended up in the DOM
2651
+ */
2652
+ function morphNode(oldNode, newContent, ctx) {
2653
+ if (ctx.ignoreActive && oldNode === document.activeElement) {
2654
+ // don't morph focused element
2655
+ return null;
2656
+ }
2494
2657
 
2495
- // remove all removed elements, after we have appended the new elements to avoid
2496
- // additional network requests for things like style sheets
2497
- for (const removedElement of removed) {
2498
- if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) {
2499
- currentHead.removeChild(removedElement);
2500
- ctx.callbacks.afterNodeRemoved(removedElement);
2501
- }
2502
- }
2658
+ if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) {
2659
+ return oldNode;
2660
+ }
2503
2661
 
2504
- ctx.head.afterHeadMorphed(currentHead, {added: added, kept: preserved, removed: removed});
2505
- return promises;
2662
+ if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) ; else if (
2663
+ oldNode instanceof HTMLHeadElement &&
2664
+ ctx.head.style !== "morph"
2665
+ ) {
2666
+ // ok to cast: if newContent wasn't also a <head>, it would've got caught in the `!isSoftMatch` branch above
2667
+ handleHeadElement(
2668
+ oldNode,
2669
+ /** @type {HTMLHeadElement} */ (newContent),
2670
+ ctx,
2671
+ );
2672
+ } else {
2673
+ morphAttributes(oldNode, newContent, ctx);
2674
+ if (!ignoreValueOfActiveElement(oldNode, ctx)) {
2675
+ // @ts-ignore newContent can be a node here because .firstChild will be null
2676
+ morphChildren(ctx, oldNode, newContent);
2506
2677
  }
2507
-
2508
- function noOp() {
2678
+ }
2679
+ ctx.callbacks.afterNodeMorphed(oldNode, newContent);
2680
+ return oldNode;
2681
+ }
2682
+
2683
+ /**
2684
+ * syncs the oldNode to the newNode, copying over all attributes and
2685
+ * inner element state from the newNode to the oldNode
2686
+ *
2687
+ * @param {Node} oldNode the node to copy attributes & state to
2688
+ * @param {Node} newNode the node to copy attributes & state from
2689
+ * @param {MorphContext} ctx the merge context
2690
+ */
2691
+ function morphAttributes(oldNode, newNode, ctx) {
2692
+ let type = newNode.nodeType;
2693
+
2694
+ // if is an element type, sync the attributes from the
2695
+ // new node into the new node
2696
+ if (type === 1 /* element type */) {
2697
+ const oldElt = /** @type {Element} */ (oldNode);
2698
+ const newElt = /** @type {Element} */ (newNode);
2699
+
2700
+ const oldAttributes = oldElt.attributes;
2701
+ const newAttributes = newElt.attributes;
2702
+ for (const newAttribute of newAttributes) {
2703
+ if (ignoreAttribute(newAttribute.name, oldElt, "update", ctx)) {
2704
+ continue;
2705
+ }
2706
+ if (oldElt.getAttribute(newAttribute.name) !== newAttribute.value) {
2707
+ oldElt.setAttribute(newAttribute.name, newAttribute.value);
2708
+ }
2509
2709
  }
2710
+ // iterate backwards to avoid skipping over items when a delete occurs
2711
+ for (let i = oldAttributes.length - 1; 0 <= i; i--) {
2712
+ const oldAttribute = oldAttributes[i];
2510
2713
 
2511
- /*
2512
- Deep merges the config object and the Idiomoroph.defaults object to
2513
- produce a final configuration object
2514
- */
2515
- function mergeDefaults(config) {
2516
- let finalConfig = {};
2517
- // copy top level stuff into final config
2518
- Object.assign(finalConfig, defaults);
2519
- Object.assign(finalConfig, config);
2520
-
2521
- // copy callbacks into final config (do this to deep merge the callbacks)
2522
- finalConfig.callbacks = {};
2523
- Object.assign(finalConfig.callbacks, defaults.callbacks);
2524
- Object.assign(finalConfig.callbacks, config.callbacks);
2525
-
2526
- // copy head config into final config (do this to deep merge the head)
2527
- finalConfig.head = {};
2528
- Object.assign(finalConfig.head, defaults.head);
2529
- Object.assign(finalConfig.head, config.head);
2530
- return finalConfig;
2531
- }
2714
+ // toAttributes is a live NamedNodeMap, so iteration+mutation is unsafe
2715
+ // e.g. custom element attribute callbacks can remove other attributes
2716
+ if (!oldAttribute) continue;
2532
2717
 
2533
- function createMorphContext(oldNode, newContent, config) {
2534
- config = mergeDefaults(config);
2535
- return {
2536
- target: oldNode,
2537
- newContent: newContent,
2538
- config: config,
2539
- morphStyle: config.morphStyle,
2540
- ignoreActive: config.ignoreActive,
2541
- ignoreActiveValue: config.ignoreActiveValue,
2542
- idMap: createIdMap(oldNode, newContent),
2543
- deadIds: new Set(),
2544
- callbacks: config.callbacks,
2545
- head: config.head
2718
+ if (!newElt.hasAttribute(oldAttribute.name)) {
2719
+ if (ignoreAttribute(oldAttribute.name, oldElt, "remove", ctx)) {
2720
+ continue;
2546
2721
  }
2722
+ oldElt.removeAttribute(oldAttribute.name);
2723
+ }
2547
2724
  }
2548
2725
 
2549
- function isIdSetMatch(node1, node2, ctx) {
2550
- if (node1 == null || node2 == null) {
2551
- return false;
2552
- }
2553
- if (node1.nodeType === node2.nodeType && node1.tagName === node2.tagName) {
2554
- if (node1.id !== "" && node1.id === node2.id) {
2555
- return true;
2556
- } else {
2557
- return getIdIntersectionCount(ctx, node1, node2) > 0;
2558
- }
2559
- }
2560
- return false;
2726
+ if (!ignoreValueOfActiveElement(oldElt, ctx)) {
2727
+ syncInputValue(oldElt, newElt, ctx);
2561
2728
  }
2729
+ }
2562
2730
 
2563
- function isSoftMatch(node1, node2) {
2564
- if (node1 == null || node2 == null) {
2565
- return false;
2566
- }
2567
- return node1.nodeType === node2.nodeType && node1.tagName === node2.tagName
2731
+ // sync text nodes
2732
+ if (type === 8 /* comment */ || type === 3 /* text */) {
2733
+ if (oldNode.nodeValue !== newNode.nodeValue) {
2734
+ oldNode.nodeValue = newNode.nodeValue;
2568
2735
  }
2736
+ }
2737
+ }
2569
2738
 
2570
- function removeNodesBetween(startInclusive, endExclusive, ctx) {
2571
- while (startInclusive !== endExclusive) {
2572
- let tempNode = startInclusive;
2573
- startInclusive = startInclusive.nextSibling;
2574
- removeNode(tempNode, ctx);
2575
- }
2576
- removeIdsFromConsideration(ctx, endExclusive);
2577
- return endExclusive.nextSibling;
2739
+ /**
2740
+ * NB: many bothans died to bring us information:
2741
+ *
2742
+ * https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js
2743
+ * https://github.com/choojs/nanomorph/blob/master/lib/morph.jsL113
2744
+ *
2745
+ * @param {Element} oldElement the element to sync the input value to
2746
+ * @param {Element} newElement the element to sync the input value from
2747
+ * @param {MorphContext} ctx the merge context
2748
+ */
2749
+ function syncInputValue(oldElement, newElement, ctx) {
2750
+ if (
2751
+ oldElement instanceof HTMLInputElement &&
2752
+ newElement instanceof HTMLInputElement &&
2753
+ newElement.type !== "file"
2754
+ ) {
2755
+ let newValue = newElement.value;
2756
+ let oldValue = oldElement.value;
2757
+
2758
+ // sync boolean attributes
2759
+ syncBooleanAttribute(oldElement, newElement, "checked", ctx);
2760
+ syncBooleanAttribute(oldElement, newElement, "disabled", ctx);
2761
+
2762
+ if (!newElement.hasAttribute("value")) {
2763
+ if (!ignoreAttribute("value", oldElement, "remove", ctx)) {
2764
+ oldElement.value = "";
2765
+ oldElement.removeAttribute("value");
2766
+ }
2767
+ } else if (oldValue !== newValue) {
2768
+ if (!ignoreAttribute("value", oldElement, "update", ctx)) {
2769
+ oldElement.setAttribute("value", newValue);
2770
+ oldElement.value = newValue;
2771
+ }
2772
+ }
2773
+ // TODO: QUESTION(1cg): this used to only check `newElement` unlike the other branches -- why?
2774
+ // did I break something?
2775
+ } else if (
2776
+ oldElement instanceof HTMLOptionElement &&
2777
+ newElement instanceof HTMLOptionElement
2778
+ ) {
2779
+ syncBooleanAttribute(oldElement, newElement, "selected", ctx);
2780
+ } else if (
2781
+ oldElement instanceof HTMLTextAreaElement &&
2782
+ newElement instanceof HTMLTextAreaElement
2783
+ ) {
2784
+ let newValue = newElement.value;
2785
+ let oldValue = oldElement.value;
2786
+ if (ignoreAttribute("value", oldElement, "update", ctx)) {
2787
+ return;
2578
2788
  }
2789
+ if (newValue !== oldValue) {
2790
+ oldElement.value = newValue;
2791
+ }
2792
+ if (
2793
+ oldElement.firstChild &&
2794
+ oldElement.firstChild.nodeValue !== newValue
2795
+ ) {
2796
+ oldElement.firstChild.nodeValue = newValue;
2797
+ }
2798
+ }
2799
+ }
2579
2800
 
2580
- //=============================================================================
2581
- // Scans forward from the insertionPoint in the old parent looking for a potential id match
2582
- // for the newChild. We stop if we find a potential id match for the new child OR
2583
- // if the number of potential id matches we are discarding is greater than the
2584
- // potential id matches for the new child
2585
- //=============================================================================
2586
- function findIdSetMatch(newContent, oldParent, newChild, insertionPoint, ctx) {
2587
-
2588
- // max id matches we are willing to discard in our search
2589
- let newChildPotentialIdCount = getIdIntersectionCount(ctx, newChild, oldParent);
2590
-
2591
- let potentialMatch = null;
2592
-
2593
- // only search forward if there is a possibility of an id match
2594
- if (newChildPotentialIdCount > 0) {
2595
- let potentialMatch = insertionPoint;
2596
- // if there is a possibility of an id match, scan forward
2597
- // keep track of the potential id match count we are discarding (the
2598
- // newChildPotentialIdCount must be greater than this to make it likely
2599
- // worth it)
2600
- let otherMatchCount = 0;
2601
- while (potentialMatch != null) {
2602
-
2603
- // If we have an id match, return the current potential match
2604
- if (isIdSetMatch(newChild, potentialMatch, ctx)) {
2605
- return potentialMatch;
2606
- }
2607
-
2608
- // computer the other potential matches of this new content
2609
- otherMatchCount += getIdIntersectionCount(ctx, potentialMatch, newContent);
2610
- if (otherMatchCount > newChildPotentialIdCount) {
2611
- // if we have more potential id matches in _other_ content, we
2612
- // do not have a good candidate for an id match, so return null
2613
- return null;
2614
- }
2615
-
2616
- // advanced to the next old content child
2617
- potentialMatch = potentialMatch.nextSibling;
2618
- }
2619
- }
2620
- return potentialMatch;
2801
+ /**
2802
+ * @param {Element} oldElement element to write the value to
2803
+ * @param {Element} newElement element to read the value from
2804
+ * @param {string} attributeName the attribute name
2805
+ * @param {MorphContext} ctx the merge context
2806
+ */
2807
+ function syncBooleanAttribute(oldElement, newElement, attributeName, ctx) {
2808
+ // @ts-ignore this function is only used on boolean attrs that are reflected as dom properties
2809
+ const newLiveValue = newElement[attributeName],
2810
+ // @ts-ignore ditto
2811
+ oldLiveValue = oldElement[attributeName];
2812
+ if (newLiveValue !== oldLiveValue) {
2813
+ const ignoreUpdate = ignoreAttribute(
2814
+ attributeName,
2815
+ oldElement,
2816
+ "update",
2817
+ ctx,
2818
+ );
2819
+ if (!ignoreUpdate) {
2820
+ // update attribute's associated DOM property
2821
+ // @ts-ignore this function is only used on boolean attrs that are reflected as dom properties
2822
+ oldElement[attributeName] = newElement[attributeName];
2823
+ }
2824
+ if (newLiveValue) {
2825
+ if (!ignoreUpdate) {
2826
+ // https://developer.mozilla.org/en-US/docs/Glossary/Boolean/HTML
2827
+ // this is the correct way to set a boolean attribute to "true"
2828
+ oldElement.setAttribute(attributeName, "");
2829
+ }
2830
+ } else {
2831
+ if (!ignoreAttribute(attributeName, oldElement, "remove", ctx)) {
2832
+ oldElement.removeAttribute(attributeName);
2833
+ }
2621
2834
  }
2835
+ }
2836
+ }
2622
2837
 
2623
- //=============================================================================
2624
- // Scans forward from the insertionPoint in the old parent looking for a potential soft match
2625
- // for the newChild. We stop if we find a potential soft match for the new child OR
2626
- // if we find a potential id match in the old parents children OR if we find two
2627
- // potential soft matches for the next two pieces of new content
2628
- //=============================================================================
2629
- function findSoftMatch(newContent, oldParent, newChild, insertionPoint, ctx) {
2630
-
2631
- let potentialSoftMatch = insertionPoint;
2632
- let nextSibling = newChild.nextSibling;
2633
- let siblingSoftMatchCount = 0;
2634
-
2635
- while (potentialSoftMatch != null) {
2636
-
2637
- if (getIdIntersectionCount(ctx, potentialSoftMatch, newContent) > 0) {
2638
- // the current potential soft match has a potential id set match with the remaining new
2639
- // content so bail out of looking
2640
- return null;
2641
- }
2642
-
2643
- // if we have a soft match with the current node, return it
2644
- if (isSoftMatch(newChild, potentialSoftMatch)) {
2645
- return potentialSoftMatch;
2646
- }
2647
-
2648
- if (isSoftMatch(nextSibling, potentialSoftMatch)) {
2649
- // the next new node has a soft match with this node, so
2650
- // increment the count of future soft matches
2651
- siblingSoftMatchCount++;
2652
- nextSibling = nextSibling.nextSibling;
2653
-
2654
- // If there are two future soft matches, bail to allow the siblings to soft match
2655
- // so that we don't consume future soft matches for the sake of the current node
2656
- if (siblingSoftMatchCount >= 2) {
2657
- return null;
2658
- }
2659
- }
2660
-
2661
- // advanced to the next old content child
2662
- potentialSoftMatch = potentialSoftMatch.nextSibling;
2663
- }
2838
+ /**
2839
+ * @param {string} attr the attribute to be mutated
2840
+ * @param {Element} element the element that is going to be updated
2841
+ * @param {"update" | "remove"} updateType
2842
+ * @param {MorphContext} ctx the merge context
2843
+ * @returns {boolean} true if the attribute should be ignored, false otherwise
2844
+ */
2845
+ function ignoreAttribute(attr, element, updateType, ctx) {
2846
+ if (
2847
+ attr === "value" &&
2848
+ ctx.ignoreActiveValue &&
2849
+ element === document.activeElement
2850
+ ) {
2851
+ return true;
2852
+ }
2853
+ return (
2854
+ ctx.callbacks.beforeAttributeUpdated(attr, element, updateType) ===
2855
+ false
2856
+ );
2857
+ }
2664
2858
 
2665
- return potentialSoftMatch;
2666
- }
2859
+ /**
2860
+ * @param {Node} possibleActiveElement
2861
+ * @param {MorphContext} ctx
2862
+ * @returns {boolean}
2863
+ */
2864
+ function ignoreValueOfActiveElement(possibleActiveElement, ctx) {
2865
+ return (
2866
+ !!ctx.ignoreActiveValue &&
2867
+ possibleActiveElement === document.activeElement &&
2868
+ possibleActiveElement !== document.body
2869
+ );
2870
+ }
2667
2871
 
2668
- function parseContent(newContent) {
2669
- let parser = new DOMParser();
2670
-
2671
- // remove svgs to avoid false-positive matches on head, etc.
2672
- let contentWithSvgsRemoved = newContent.replace(/<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim, '');
2673
-
2674
- // if the newContent contains a html, head or body tag, we can simply parse it w/o wrapping
2675
- if (contentWithSvgsRemoved.match(/<\/html>/) || contentWithSvgsRemoved.match(/<\/head>/) || contentWithSvgsRemoved.match(/<\/body>/)) {
2676
- let content = parser.parseFromString(newContent, "text/html");
2677
- // if it is a full HTML document, return the document itself as the parent container
2678
- if (contentWithSvgsRemoved.match(/<\/html>/)) {
2679
- content.generatedByIdiomorph = true;
2680
- return content;
2681
- } else {
2682
- // otherwise return the html element as the parent container
2683
- let htmlElement = content.firstChild;
2684
- if (htmlElement) {
2685
- htmlElement.generatedByIdiomorph = true;
2686
- return htmlElement;
2687
- } else {
2688
- return null;
2689
- }
2690
- }
2691
- } else {
2692
- // if it is partial HTML, wrap it in a template tag to provide a parent element and also to help
2693
- // deal with touchy tags like tr, tbody, etc.
2694
- let responseDoc = parser.parseFromString("<body><template>" + newContent + "</template></body>", "text/html");
2695
- let content = responseDoc.body.querySelector('template').content;
2696
- content.generatedByIdiomorph = true;
2697
- return content
2698
- }
2699
- }
2872
+ return morphNode;
2873
+ })();
2700
2874
 
2701
- function normalizeContent(newContent) {
2702
- if (newContent == null) {
2703
- // noinspection UnnecessaryLocalVariableJS
2704
- const dummyParent = document.createElement('div');
2705
- return dummyParent;
2706
- } else if (newContent.generatedByIdiomorph) {
2707
- // the template tag created by idiomorph parsing can serve as a dummy parent
2708
- return newContent;
2709
- } else if (newContent instanceof Node) {
2710
- // a single node is added as a child to a dummy parent
2711
- const dummyParent = document.createElement('div');
2712
- dummyParent.append(newContent);
2713
- return dummyParent;
2714
- } else {
2715
- // all nodes in the array or HTMLElement collection are consolidated under
2716
- // a single dummy parent element
2717
- const dummyParent = document.createElement('div');
2718
- for (const elt of [...newContent]) {
2719
- dummyParent.append(elt);
2720
- }
2721
- return dummyParent;
2722
- }
2723
- }
2875
+ //=============================================================================
2876
+ // Head Management Functions
2877
+ //=============================================================================
2878
+ /**
2879
+ * @param {MorphContext} ctx
2880
+ * @param {Element} oldNode
2881
+ * @param {Element} newNode
2882
+ * @param {function} callback
2883
+ * @returns {Node[] | Promise<Node[]>}
2884
+ */
2885
+ function withHeadBlocking(ctx, oldNode, newNode, callback) {
2886
+ if (ctx.head.block) {
2887
+ const oldHead = oldNode.querySelector("head");
2888
+ const newHead = newNode.querySelector("head");
2889
+ if (oldHead && newHead) {
2890
+ const promises = handleHeadElement(oldHead, newHead, ctx);
2891
+ // when head promises resolve, proceed ignoring the head tag
2892
+ return Promise.all(promises).then(() => {
2893
+ const newCtx = Object.assign(ctx, {
2894
+ head: {
2895
+ block: false,
2896
+ ignore: true,
2897
+ },
2898
+ });
2899
+ return callback(newCtx);
2900
+ });
2901
+ }
2902
+ }
2903
+ // just proceed if we not head blocking
2904
+ return callback(ctx);
2905
+ }
2724
2906
 
2725
- function insertSiblings(previousSibling, morphedNode, nextSibling) {
2726
- let stack = [];
2727
- let added = [];
2728
- while (previousSibling != null) {
2729
- stack.push(previousSibling);
2730
- previousSibling = previousSibling.previousSibling;
2731
- }
2732
- while (stack.length > 0) {
2733
- let node = stack.pop();
2734
- added.push(node); // push added preceding siblings on in order and insert
2735
- morphedNode.parentElement.insertBefore(node, morphedNode);
2736
- }
2737
- added.push(morphedNode);
2738
- while (nextSibling != null) {
2739
- stack.push(nextSibling);
2740
- added.push(nextSibling); // here we are going in order, so push on as we scan, rather than add
2741
- nextSibling = nextSibling.nextSibling;
2742
- }
2743
- while (stack.length > 0) {
2744
- morphedNode.parentElement.insertBefore(stack.pop(), morphedNode.nextSibling);
2745
- }
2746
- return added;
2907
+ /**
2908
+ * The HEAD tag can be handled specially, either w/ a 'merge' or 'append' style
2909
+ *
2910
+ * @param {Element} oldHead
2911
+ * @param {Element} newHead
2912
+ * @param {MorphContext} ctx
2913
+ * @returns {Promise<void>[]}
2914
+ */
2915
+ function handleHeadElement(oldHead, newHead, ctx) {
2916
+ let added = [];
2917
+ let removed = [];
2918
+ let preserved = [];
2919
+ let nodesToAppend = [];
2920
+
2921
+ // put all new head elements into a Map, by their outerHTML
2922
+ let srcToNewHeadNodes = new Map();
2923
+ for (const newHeadChild of newHead.children) {
2924
+ srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild);
2925
+ }
2926
+
2927
+ // for each elt in the current head
2928
+ for (const currentHeadElt of oldHead.children) {
2929
+ // If the current head element is in the map
2930
+ let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML);
2931
+ let isReAppended = ctx.head.shouldReAppend(currentHeadElt);
2932
+ let isPreserved = ctx.head.shouldPreserve(currentHeadElt);
2933
+ if (inNewContent || isPreserved) {
2934
+ if (isReAppended) {
2935
+ // remove the current version and let the new version replace it and re-execute
2936
+ removed.push(currentHeadElt);
2937
+ } else {
2938
+ // this element already exists and should not be re-appended, so remove it from
2939
+ // the new content map, preserving it in the DOM
2940
+ srcToNewHeadNodes.delete(currentHeadElt.outerHTML);
2941
+ preserved.push(currentHeadElt);
2747
2942
  }
2748
-
2749
- function findBestNodeMatch(newContent, oldNode, ctx) {
2750
- let currentElement;
2751
- currentElement = newContent.firstChild;
2752
- let bestElement = currentElement;
2753
- let score = 0;
2754
- while (currentElement) {
2755
- let newScore = scoreElement(currentElement, oldNode, ctx);
2756
- if (newScore > score) {
2757
- bestElement = currentElement;
2758
- score = newScore;
2759
- }
2760
- currentElement = currentElement.nextSibling;
2761
- }
2762
- return bestElement;
2943
+ } else {
2944
+ if (ctx.head.style === "append") {
2945
+ // we are appending and this existing element is not new content
2946
+ // so if and only if it is marked for re-append do we do anything
2947
+ if (isReAppended) {
2948
+ removed.push(currentHeadElt);
2949
+ nodesToAppend.push(currentHeadElt);
2950
+ }
2951
+ } else {
2952
+ // if this is a merge, we remove this content since it is not in the new head
2953
+ if (ctx.head.shouldRemove(currentHeadElt) !== false) {
2954
+ removed.push(currentHeadElt);
2955
+ }
2763
2956
  }
2957
+ }
2958
+ }
2764
2959
 
2765
- function scoreElement(node1, node2, ctx) {
2766
- if (isSoftMatch(node1, node2)) {
2767
- return .5 + getIdIntersectionCount(ctx, node1, node2);
2768
- }
2769
- return 0;
2960
+ // Push the remaining new head elements in the Map into the
2961
+ // nodes to append to the head tag
2962
+ nodesToAppend.push(...srcToNewHeadNodes.values());
2963
+
2964
+ let promises = [];
2965
+ for (const newNode of nodesToAppend) {
2966
+ // TODO: This could theoretically be null, based on type
2967
+ let newElt = /** @type {ChildNode} */ (
2968
+ document.createRange().createContextualFragment(newNode.outerHTML)
2969
+ .firstChild
2970
+ );
2971
+ if (ctx.callbacks.beforeNodeAdded(newElt) !== false) {
2972
+ if (
2973
+ ("href" in newElt && newElt.href) ||
2974
+ ("src" in newElt && newElt.src)
2975
+ ) {
2976
+ /** @type {(result?: any) => void} */ let resolve;
2977
+ let promise = new Promise(function (_resolve) {
2978
+ resolve = _resolve;
2979
+ });
2980
+ newElt.addEventListener("load", function () {
2981
+ resolve();
2982
+ });
2983
+ promises.push(promise);
2770
2984
  }
2985
+ oldHead.appendChild(newElt);
2986
+ ctx.callbacks.afterNodeAdded(newElt);
2987
+ added.push(newElt);
2988
+ }
2989
+ }
2771
2990
 
2772
- function removeNode(tempNode, ctx) {
2773
- removeIdsFromConsideration(ctx, tempNode);
2774
- if (ctx.callbacks.beforeNodeRemoved(tempNode) === false) return;
2991
+ // remove all removed elements, after we have appended the new elements to avoid
2992
+ // additional network requests for things like style sheets
2993
+ for (const removedElement of removed) {
2994
+ if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) {
2995
+ oldHead.removeChild(removedElement);
2996
+ ctx.callbacks.afterNodeRemoved(removedElement);
2997
+ }
2998
+ }
2775
2999
 
2776
- tempNode.remove();
2777
- ctx.callbacks.afterNodeRemoved(tempNode);
2778
- }
3000
+ ctx.head.afterHeadMorphed(oldHead, {
3001
+ added: added,
3002
+ kept: preserved,
3003
+ removed: removed,
3004
+ });
3005
+ return promises;
3006
+ }
3007
+
3008
+ //=============================================================================
3009
+ // Create Morph Context Functions
3010
+ //=============================================================================
3011
+ const createMorphContext = (function () {
3012
+ /**
3013
+ *
3014
+ * @param {Element} oldNode
3015
+ * @param {Element} newContent
3016
+ * @param {Config} config
3017
+ * @returns {MorphContext}
3018
+ */
3019
+ function createMorphContext(oldNode, newContent, config) {
3020
+ const { persistentIds, idMap } = createIdMaps(oldNode, newContent);
3021
+
3022
+ const mergedConfig = mergeDefaults(config);
3023
+ const morphStyle = mergedConfig.morphStyle || "outerHTML";
3024
+ if (!["innerHTML", "outerHTML"].includes(morphStyle)) {
3025
+ throw `Do not understand how to morph style ${morphStyle}`;
3026
+ }
2779
3027
 
2780
- //=============================================================================
2781
- // ID Set Functions
2782
- //=============================================================================
3028
+ return {
3029
+ target: oldNode,
3030
+ newContent: newContent,
3031
+ config: mergedConfig,
3032
+ morphStyle: morphStyle,
3033
+ ignoreActive: mergedConfig.ignoreActive,
3034
+ ignoreActiveValue: mergedConfig.ignoreActiveValue,
3035
+ restoreFocus: mergedConfig.restoreFocus,
3036
+ idMap: idMap,
3037
+ persistentIds: persistentIds,
3038
+ pantry: createPantry(),
3039
+ callbacks: mergedConfig.callbacks,
3040
+ head: mergedConfig.head,
3041
+ };
3042
+ }
2783
3043
 
2784
- function isIdInConsideration(ctx, id) {
2785
- return !ctx.deadIds.has(id);
2786
- }
3044
+ /**
3045
+ * Deep merges the config object and the Idiomorph.defaults object to
3046
+ * produce a final configuration object
3047
+ * @param {Config} config
3048
+ * @returns {ConfigInternal}
3049
+ */
3050
+ function mergeDefaults(config) {
3051
+ let finalConfig = Object.assign({}, defaults);
2787
3052
 
2788
- function idIsWithinNode(ctx, id, targetNode) {
2789
- let idSet = ctx.idMap.get(targetNode) || EMPTY_SET;
2790
- return idSet.has(id);
2791
- }
3053
+ // copy top level stuff into final config
3054
+ Object.assign(finalConfig, config);
2792
3055
 
2793
- function removeIdsFromConsideration(ctx, node) {
2794
- let idSet = ctx.idMap.get(node) || EMPTY_SET;
2795
- for (const id of idSet) {
2796
- ctx.deadIds.add(id);
3056
+ // copy callbacks into final config (do this to deep merge the callbacks)
3057
+ finalConfig.callbacks = Object.assign(
3058
+ {},
3059
+ defaults.callbacks,
3060
+ config.callbacks,
3061
+ );
3062
+
3063
+ // copy head config into final config (do this to deep merge the head)
3064
+ finalConfig.head = Object.assign({}, defaults.head, config.head);
3065
+
3066
+ return finalConfig;
3067
+ }
3068
+
3069
+ /**
3070
+ * @returns {HTMLDivElement}
3071
+ */
3072
+ function createPantry() {
3073
+ const pantry = document.createElement("div");
3074
+ pantry.hidden = true;
3075
+ document.body.insertAdjacentElement("afterend", pantry);
3076
+ return pantry;
3077
+ }
3078
+
3079
+ /**
3080
+ * Returns all elements with an ID contained within the root element and its descendants
3081
+ *
3082
+ * @param {Element} root
3083
+ * @returns {Element[]}
3084
+ */
3085
+ function findIdElements(root) {
3086
+ let elements = Array.from(root.querySelectorAll("[id]"));
3087
+ if (root.id) {
3088
+ elements.push(root);
3089
+ }
3090
+ return elements;
3091
+ }
3092
+
3093
+ /**
3094
+ * A bottom-up algorithm that populates a map of Element -> IdSet.
3095
+ * The idSet for a given element is the set of all IDs contained within its subtree.
3096
+ * As an optimzation, we filter these IDs through the given list of persistent IDs,
3097
+ * because we don't need to bother considering IDed elements that won't be in the new content.
3098
+ *
3099
+ * @param {Map<Node, Set<string>>} idMap
3100
+ * @param {Set<string>} persistentIds
3101
+ * @param {Element} root
3102
+ * @param {Element[]} elements
3103
+ */
3104
+ function populateIdMapWithTree(idMap, persistentIds, root, elements) {
3105
+ for (const elt of elements) {
3106
+ if (persistentIds.has(elt.id)) {
3107
+ /** @type {Element|null} */
3108
+ let current = elt;
3109
+ // walk up the parent hierarchy of that element, adding the id
3110
+ // of element to the parent's id set
3111
+ while (current) {
3112
+ let idSet = idMap.get(current);
3113
+ // if the id set doesn't exist, create it and insert it in the map
3114
+ if (idSet == null) {
3115
+ idSet = new Set();
3116
+ idMap.set(current, idSet);
2797
3117
  }
3118
+ idSet.add(elt.id);
3119
+
3120
+ if (current === root) break;
3121
+ current = current.parentElement;
3122
+ }
2798
3123
  }
3124
+ }
3125
+ }
2799
3126
 
2800
- function getIdIntersectionCount(ctx, node1, node2) {
2801
- let sourceSet = ctx.idMap.get(node1) || EMPTY_SET;
2802
- let matchCount = 0;
2803
- for (const id of sourceSet) {
2804
- // a potential match is an id in the source and potentialIdsSet, but
2805
- // that has not already been merged into the DOM
2806
- if (isIdInConsideration(ctx, id) && idIsWithinNode(ctx, id, node2)) {
2807
- ++matchCount;
2808
- }
2809
- }
2810
- return matchCount;
3127
+ /**
3128
+ * This function computes a map of nodes to all ids contained within that node (inclusive of the
3129
+ * node). This map can be used to ask if two nodes have intersecting sets of ids, which allows
3130
+ * for a looser definition of "matching" than tradition id matching, and allows child nodes
3131
+ * to contribute to a parent nodes matching.
3132
+ *
3133
+ * @param {Element} oldContent the old content that will be morphed
3134
+ * @param {Element} newContent the new content to morph to
3135
+ * @returns {IdSets}
3136
+ */
3137
+ function createIdMaps(oldContent, newContent) {
3138
+ const oldIdElements = findIdElements(oldContent);
3139
+ const newIdElements = findIdElements(newContent);
3140
+
3141
+ const persistentIds = createPersistentIds(oldIdElements, newIdElements);
3142
+
3143
+ /** @type {Map<Node, Set<string>>} */
3144
+ let idMap = new Map();
3145
+ populateIdMapWithTree(idMap, persistentIds, oldContent, oldIdElements);
3146
+
3147
+ /** @ts-ignore - if newContent is a duck-typed parent, pass its single child node as the root to halt upwards iteration */
3148
+ const newRoot = newContent.__idiomorphRoot || newContent;
3149
+ populateIdMapWithTree(idMap, persistentIds, newRoot, newIdElements);
3150
+
3151
+ return { persistentIds, idMap };
3152
+ }
3153
+
3154
+ /**
3155
+ * This function computes the set of ids that persist between the two contents excluding duplicates
3156
+ *
3157
+ * @param {Element[]} oldIdElements
3158
+ * @param {Element[]} newIdElements
3159
+ * @returns {Set<string>}
3160
+ */
3161
+ function createPersistentIds(oldIdElements, newIdElements) {
3162
+ let duplicateIds = new Set();
3163
+
3164
+ /** @type {Map<string, string>} */
3165
+ let oldIdTagNameMap = new Map();
3166
+ for (const { id, tagName } of oldIdElements) {
3167
+ if (oldIdTagNameMap.has(id)) {
3168
+ duplicateIds.add(id);
3169
+ } else {
3170
+ oldIdTagNameMap.set(id, tagName);
2811
3171
  }
3172
+ }
2812
3173
 
2813
- /**
2814
- * A bottom up algorithm that finds all elements with ids inside of the node
2815
- * argument and populates id sets for those nodes and all their parents, generating
2816
- * a set of ids contained within all nodes for the entire hierarchy in the DOM
2817
- *
2818
- * @param node {Element}
2819
- * @param {Map<Node, Set<String>>} idMap
2820
- */
2821
- function populateIdMapForNode(node, idMap) {
2822
- let nodeParent = node.parentElement;
2823
- // find all elements with an id property
2824
- let idElements = node.querySelectorAll('[id]');
2825
- for (const elt of idElements) {
2826
- let current = elt;
2827
- // walk up the parent hierarchy of that element, adding the id
2828
- // of element to the parent's id set
2829
- while (current !== nodeParent && current != null) {
2830
- let idSet = idMap.get(current);
2831
- // if the id set doesn't exist, create it and insert it in the map
2832
- if (idSet == null) {
2833
- idSet = new Set();
2834
- idMap.set(current, idSet);
2835
- }
2836
- idSet.add(elt.id);
2837
- current = current.parentElement;
2838
- }
2839
- }
3174
+ let persistentIds = new Set();
3175
+ for (const { id, tagName } of newIdElements) {
3176
+ if (persistentIds.has(id)) {
3177
+ duplicateIds.add(id);
3178
+ } else if (oldIdTagNameMap.get(id) === tagName) {
3179
+ persistentIds.add(id);
2840
3180
  }
3181
+ // skip if tag types mismatch because its not possible to morph one tag into another
3182
+ }
2841
3183
 
2842
- /**
2843
- * This function computes a map of nodes to all ids contained within that node (inclusive of the
2844
- * node). This map can be used to ask if two nodes have intersecting sets of ids, which allows
2845
- * for a looser definition of "matching" than tradition id matching, and allows child nodes
2846
- * to contribute to a parent nodes matching.
2847
- *
2848
- * @param {Element} oldContent the old content that will be morphed
2849
- * @param {Element} newContent the new content to morph to
2850
- * @returns {Map<Node, Set<String>>} a map of nodes to id sets for the
2851
- */
2852
- function createIdMap(oldContent, newContent) {
2853
- let idMap = new Map();
2854
- populateIdMapForNode(oldContent, idMap);
2855
- populateIdMapForNode(newContent, idMap);
2856
- return idMap;
3184
+ for (const id of duplicateIds) {
3185
+ persistentIds.delete(id);
3186
+ }
3187
+ return persistentIds;
3188
+ }
3189
+
3190
+ return createMorphContext;
3191
+ })();
3192
+
3193
+ //=============================================================================
3194
+ // HTML Normalization Functions
3195
+ //=============================================================================
3196
+ const { normalizeElement, normalizeParent } = (function () {
3197
+ /** @type {WeakSet<Node>} */
3198
+ const generatedByIdiomorph = new WeakSet();
3199
+
3200
+ /**
3201
+ *
3202
+ * @param {Element | Document} content
3203
+ * @returns {Element}
3204
+ */
3205
+ function normalizeElement(content) {
3206
+ if (content instanceof Document) {
3207
+ return content.documentElement;
3208
+ } else {
3209
+ return content;
3210
+ }
3211
+ }
3212
+
3213
+ /**
3214
+ *
3215
+ * @param {null | string | Node | HTMLCollection | Node[] | Document & {generatedByIdiomorph:boolean}} newContent
3216
+ * @returns {Element}
3217
+ */
3218
+ function normalizeParent(newContent) {
3219
+ if (newContent == null) {
3220
+ return document.createElement("div"); // dummy parent element
3221
+ } else if (typeof newContent === "string") {
3222
+ return normalizeParent(parseContent(newContent));
3223
+ } else if (
3224
+ generatedByIdiomorph.has(/** @type {Element} */ (newContent))
3225
+ ) {
3226
+ // the template tag created by idiomorph parsing can serve as a dummy parent
3227
+ return /** @type {Element} */ (newContent);
3228
+ } else if (newContent instanceof Node) {
3229
+ if (newContent.parentNode) {
3230
+ // we can't use the parent directly because newContent may have siblings
3231
+ // that we don't want in the morph, and reparenting might be expensive (TODO is it?),
3232
+ // so we create a duck-typed parent node instead.
3233
+ return createDuckTypedParent(newContent);
3234
+ } else {
3235
+ // a single node is added as a child to a dummy parent
3236
+ const dummyParent = document.createElement("div");
3237
+ dummyParent.append(newContent);
3238
+ return dummyParent;
3239
+ }
3240
+ } else {
3241
+ // all nodes in the array or HTMLElement collection are consolidated under
3242
+ // a single dummy parent element
3243
+ const dummyParent = document.createElement("div");
3244
+ for (const elt of [...newContent]) {
3245
+ dummyParent.append(elt);
2857
3246
  }
3247
+ return dummyParent;
3248
+ }
3249
+ }
2858
3250
 
2859
- //=============================================================================
2860
- // This is what ends up becoming the Idiomorph global object
2861
- //=============================================================================
2862
- return {
2863
- morph,
2864
- defaults
3251
+ /**
3252
+ * Creates a fake duck-typed parent element to wrap a single node, without actually reparenting it.
3253
+ * "If it walks like a duck, and quacks like a duck, then it must be a duck!" -- James Whitcomb Riley (1849–1916)
3254
+ *
3255
+ * @param {Node} newContent
3256
+ * @returns {Element}
3257
+ */
3258
+ function createDuckTypedParent(newContent) {
3259
+ return /** @type {Element} */ (
3260
+ /** @type {unknown} */ ({
3261
+ childNodes: [newContent],
3262
+ /** @ts-ignore - cover your eyes for a minute, tsc */
3263
+ querySelectorAll: (s) => {
3264
+ /** @ts-ignore */
3265
+ const elements = newContent.querySelectorAll(s);
3266
+ /** @ts-ignore */
3267
+ return newContent.matches(s) ? [newContent, ...elements] : elements;
3268
+ },
3269
+ /** @ts-ignore */
3270
+ insertBefore: (n, r) => newContent.parentNode.insertBefore(n, r),
3271
+ /** @ts-ignore */
3272
+ moveBefore: (n, r) => newContent.parentNode.moveBefore(n, r),
3273
+ // for later use with populateIdMapWithTree to halt upwards iteration
3274
+ get __idiomorphRoot() {
3275
+ return newContent;
3276
+ },
3277
+ })
3278
+ );
3279
+ }
3280
+
3281
+ /**
3282
+ *
3283
+ * @param {string} newContent
3284
+ * @returns {Node | null | DocumentFragment}
3285
+ */
3286
+ function parseContent(newContent) {
3287
+ let parser = new DOMParser();
3288
+
3289
+ // remove svgs to avoid false-positive matches on head, etc.
3290
+ let contentWithSvgsRemoved = newContent.replace(
3291
+ /<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim,
3292
+ "",
3293
+ );
3294
+
3295
+ // if the newContent contains a html, head or body tag, we can simply parse it w/o wrapping
3296
+ if (
3297
+ contentWithSvgsRemoved.match(/<\/html>/) ||
3298
+ contentWithSvgsRemoved.match(/<\/head>/) ||
3299
+ contentWithSvgsRemoved.match(/<\/body>/)
3300
+ ) {
3301
+ let content = parser.parseFromString(newContent, "text/html");
3302
+ // if it is a full HTML document, return the document itself as the parent container
3303
+ if (contentWithSvgsRemoved.match(/<\/html>/)) {
3304
+ generatedByIdiomorph.add(content);
3305
+ return content;
3306
+ } else {
3307
+ // otherwise return the html element as the parent container
3308
+ let htmlElement = content.firstChild;
3309
+ if (htmlElement) {
3310
+ generatedByIdiomorph.add(htmlElement);
3311
+ }
3312
+ return htmlElement;
2865
3313
  }
2866
- })();
3314
+ } else {
3315
+ // if it is partial HTML, wrap it in a template tag to provide a parent element and also to help
3316
+ // deal with touchy tags like tr, tbody, etc.
3317
+ let responseDoc = parser.parseFromString(
3318
+ "<body><template>" + newContent + "</template></body>",
3319
+ "text/html",
3320
+ );
3321
+ let content = /** @type {HTMLTemplateElement} */ (
3322
+ responseDoc.body.querySelector("template")
3323
+ ).content;
3324
+ generatedByIdiomorph.add(content);
3325
+ return content;
3326
+ }
3327
+ }
3328
+
3329
+ return { normalizeElement, normalizeParent };
3330
+ })();
3331
+
3332
+ //=============================================================================
3333
+ // This is what ends up becoming the Idiomorph global object
3334
+ //=============================================================================
3335
+ return {
3336
+ morph,
3337
+ defaults,
3338
+ };
3339
+ })();
2867
3340
 
2868
3341
  function morphElements(currentElement, newElement, { callbacks, ...options } = {}) {
2869
3342
  Idiomorph.morph(currentElement, newElement, {
@@ -2873,7 +3346,7 @@ Copyright © 2024 37signals LLC
2873
3346
  }
2874
3347
 
2875
3348
  function morphChildren(currentElement, newElement) {
2876
- morphElements(currentElement, newElement.children, {
3349
+ morphElements(currentElement, newElement.childNodes, {
2877
3350
  morphStyle: "innerHTML"
2878
3351
  });
2879
3352
  }
@@ -3666,16 +4139,6 @@ Copyright © 2024 37signals LLC
3666
4139
 
3667
4140
  // Private
3668
4141
 
3669
- getHistoryMethodForAction(action) {
3670
- switch (action) {
3671
- case "replace":
3672
- return history.replaceState
3673
- case "advance":
3674
- case "restore":
3675
- return history.pushState
3676
- }
3677
- }
3678
-
3679
4142
  hasPreloadedResponse() {
3680
4143
  return typeof this.response == "object"
3681
4144
  }
@@ -3795,6 +4258,12 @@ Copyright © 2024 37signals LLC
3795
4258
 
3796
4259
  visitRendered(_visit) {}
3797
4260
 
4261
+ // Link prefetching
4262
+
4263
+ linkPrefetchingIsEnabledForLocation(location) {
4264
+ return true
4265
+ }
4266
+
3798
4267
  // Form Submission Delegate
3799
4268
 
3800
4269
  formSubmissionStarted(_formSubmission) {
@@ -4379,6 +4848,17 @@ Copyright © 2024 37signals LLC
4379
4848
  }
4380
4849
  }
4381
4850
 
4851
+ // Link prefetching
4852
+
4853
+ linkPrefetchingIsEnabledForLocation(location) {
4854
+ // Not all adapters implement linkPrefetchingIsEnabledForLocation
4855
+ if (typeof this.adapter.linkPrefetchingIsEnabledForLocation === "function") {
4856
+ return this.adapter.linkPrefetchingIsEnabledForLocation(location)
4857
+ }
4858
+
4859
+ return true
4860
+ }
4861
+
4382
4862
  // Visit delegate
4383
4863
 
4384
4864
  visitStarted(visit) {
@@ -5285,7 +5765,8 @@ Copyright © 2024 37signals LLC
5285
5765
 
5286
5766
  refresh(url, requestId) {
5287
5767
  const isRecentRequest = requestId && this.recentRequests.has(requestId);
5288
- if (!isRecentRequest && !this.navigator.currentVisit) {
5768
+ const isCurrentUrl = url === document.baseURI;
5769
+ if (!isRecentRequest && !this.navigator.currentVisit && isCurrentUrl) {
5289
5770
  this.visit(url, { action: "replace", shouldCacheSnapshot: false });
5290
5771
  }
5291
5772
  }
@@ -5409,7 +5890,8 @@ Copyright © 2024 37signals LLC
5409
5890
  canPrefetchRequestToLocation(link, location) {
5410
5891
  return (
5411
5892
  this.elementIsNavigatable(link) &&
5412
- locationIsVisitable(location, this.snapshot.rootLocation)
5893
+ locationIsVisitable(location, this.snapshot.rootLocation) &&
5894
+ this.navigator.linkPrefetchingIsEnabledForLocation(location)
5413
5895
  )
5414
5896
  }
5415
5897
 
@@ -6513,10 +6995,10 @@ Copyright © 2024 37signals LLC
6513
6995
  * Gets the list of duplicate children (i.e. those with the same ID)
6514
6996
  */
6515
6997
  get duplicateChildren() {
6516
- const existingChildren = this.targetElements.flatMap((e) => [...e.children]).filter((c) => !!c.id);
6517
- const newChildrenIds = [...(this.templateContent?.children || [])].filter((c) => !!c.id).map((c) => c.id);
6998
+ const existingChildren = this.targetElements.flatMap((e) => [...e.children]).filter((c) => !!c.getAttribute("id"));
6999
+ const newChildrenIds = [...(this.templateContent?.children || [])].filter((c) => !!c.getAttribute("id")).map((c) => c.getAttribute("id"));
6518
7000
 
6519
- return existingChildren.filter((c) => newChildrenIds.includes(c.id))
7001
+ return existingChildren.filter((c) => newChildrenIds.includes(c.getAttribute("id")))
6520
7002
  }
6521
7003
 
6522
7004
  /**