@hotwired/turbo 8.0.12 → 8.0.14

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.12
3
- Copyright © 2024 37signals LLC
2
+ Turbo 8.0.13
3
+ Copyright © 2025 37signals LLC
4
4
  */
5
5
  /**
6
6
  * The MIT License (MIT)
@@ -555,7 +555,13 @@ function doesNotTargetIFrame(name) {
555
555
  }
556
556
 
557
557
  function findLinkFromClickTarget(target) {
558
- return findClosestRecursively(target, "a[href]:not([target^=_]):not([download])")
558
+ const link = findClosestRecursively(target, "a[href], a[xlink\\:href]");
559
+
560
+ if (!link) return null
561
+ if (link.hasAttribute("download")) return null
562
+ if (link.hasAttribute("target") && link.target !== "_self") return null
563
+
564
+ return link
559
565
  }
560
566
 
561
567
  function getLocationForLink(link) {
@@ -642,8 +648,8 @@ function getExtension(url) {
642
648
  }
643
649
 
644
650
  function isPrefixedBy(baseURL, url) {
645
- const prefix = getPrefix(url);
646
- return baseURL.href === expandURL(prefix).href || baseURL.href.startsWith(prefix)
651
+ const prefix = addTrailingSlash(url.origin + url.pathname);
652
+ return addTrailingSlash(baseURL.href) === prefix || baseURL.href.startsWith(prefix)
647
653
  }
648
654
 
649
655
  function locationIsVisitable(location, rootLocation) {
@@ -671,10 +677,6 @@ function getLastPathComponent(url) {
671
677
  return getPathComponents(url).slice(-1)[0]
672
678
  }
673
679
 
674
- function getPrefix(url) {
675
- return addTrailingSlash(url.origin + url.pathname)
676
- }
677
-
678
680
  function addTrailingSlash(value) {
679
681
  return value.endsWith("/") ? value : value + "/"
680
682
  }
@@ -755,15 +757,13 @@ class LimitedSet extends Set {
755
757
 
756
758
  const recentRequests = new LimitedSet(20);
757
759
 
758
- const nativeFetch = window.fetch;
759
-
760
760
  function fetchWithTurboHeaders(url, options = {}) {
761
761
  const modifiedHeaders = new Headers(options.headers || {});
762
762
  const requestUID = uuid();
763
763
  recentRequests.add(requestUID);
764
764
  modifiedHeaders.append("X-Turbo-Request-Id", requestUID);
765
765
 
766
- return nativeFetch(url, {
766
+ return window.fetch(url, {
767
767
  ...options,
768
768
  headers: modifiedHeaders
769
769
  })
@@ -1499,8 +1499,8 @@ class View {
1499
1499
  scrollToAnchor(anchor) {
1500
1500
  const element = this.snapshot.getElementForAnchor(anchor);
1501
1501
  if (element) {
1502
- this.scrollToElement(element);
1503
1502
  this.focusElement(element);
1503
+ this.scrollToElement(element);
1504
1504
  } else {
1505
1505
  this.scrollToPosition({ x: 0, y: 0 });
1506
1506
  }
@@ -2026,839 +2026,1320 @@ function readScrollBehavior(value, defaultValue) {
2026
2026
  }
2027
2027
  }
2028
2028
 
2029
+ /**
2030
+ * @typedef {object} ConfigHead
2031
+ *
2032
+ * @property {'merge' | 'append' | 'morph' | 'none'} [style]
2033
+ * @property {boolean} [block]
2034
+ * @property {boolean} [ignore]
2035
+ * @property {function(Element): boolean} [shouldPreserve]
2036
+ * @property {function(Element): boolean} [shouldReAppend]
2037
+ * @property {function(Element): boolean} [shouldRemove]
2038
+ * @property {function(Element, {added: Node[], kept: Element[], removed: Element[]}): void} [afterHeadMorphed]
2039
+ */
2040
+
2041
+ /**
2042
+ * @typedef {object} ConfigCallbacks
2043
+ *
2044
+ * @property {function(Node): boolean} [beforeNodeAdded]
2045
+ * @property {function(Node): void} [afterNodeAdded]
2046
+ * @property {function(Element, Node): boolean} [beforeNodeMorphed]
2047
+ * @property {function(Element, Node): void} [afterNodeMorphed]
2048
+ * @property {function(Element): boolean} [beforeNodeRemoved]
2049
+ * @property {function(Element): void} [afterNodeRemoved]
2050
+ * @property {function(string, Element, "update" | "remove"): boolean} [beforeAttributeUpdated]
2051
+ */
2052
+
2053
+ /**
2054
+ * @typedef {object} Config
2055
+ *
2056
+ * @property {'outerHTML' | 'innerHTML'} [morphStyle]
2057
+ * @property {boolean} [ignoreActive]
2058
+ * @property {boolean} [ignoreActiveValue]
2059
+ * @property {boolean} [restoreFocus]
2060
+ * @property {ConfigCallbacks} [callbacks]
2061
+ * @property {ConfigHead} [head]
2062
+ */
2063
+
2064
+ /**
2065
+ * @typedef {function} NoOp
2066
+ *
2067
+ * @returns {void}
2068
+ */
2069
+
2070
+ /**
2071
+ * @typedef {object} ConfigHeadInternal
2072
+ *
2073
+ * @property {'merge' | 'append' | 'morph' | 'none'} style
2074
+ * @property {boolean} [block]
2075
+ * @property {boolean} [ignore]
2076
+ * @property {(function(Element): boolean) | NoOp} shouldPreserve
2077
+ * @property {(function(Element): boolean) | NoOp} shouldReAppend
2078
+ * @property {(function(Element): boolean) | NoOp} shouldRemove
2079
+ * @property {(function(Element, {added: Node[], kept: Element[], removed: Element[]}): void) | NoOp} afterHeadMorphed
2080
+ */
2081
+
2082
+ /**
2083
+ * @typedef {object} ConfigCallbacksInternal
2084
+ *
2085
+ * @property {(function(Node): boolean) | NoOp} beforeNodeAdded
2086
+ * @property {(function(Node): void) | NoOp} afterNodeAdded
2087
+ * @property {(function(Node, Node): boolean) | NoOp} beforeNodeMorphed
2088
+ * @property {(function(Node, Node): void) | NoOp} afterNodeMorphed
2089
+ * @property {(function(Node): boolean) | NoOp} beforeNodeRemoved
2090
+ * @property {(function(Node): void) | NoOp} afterNodeRemoved
2091
+ * @property {(function(string, Element, "update" | "remove"): boolean) | NoOp} beforeAttributeUpdated
2092
+ */
2093
+
2094
+ /**
2095
+ * @typedef {object} ConfigInternal
2096
+ *
2097
+ * @property {'outerHTML' | 'innerHTML'} morphStyle
2098
+ * @property {boolean} [ignoreActive]
2099
+ * @property {boolean} [ignoreActiveValue]
2100
+ * @property {boolean} [restoreFocus]
2101
+ * @property {ConfigCallbacksInternal} callbacks
2102
+ * @property {ConfigHeadInternal} head
2103
+ */
2104
+
2105
+ /**
2106
+ * @typedef {Object} IdSets
2107
+ * @property {Set<string>} persistentIds
2108
+ * @property {Map<Node, Set<string>>} idMap
2109
+ */
2110
+
2111
+ /**
2112
+ * @typedef {Function} Morph
2113
+ *
2114
+ * @param {Element | Document} oldNode
2115
+ * @param {Element | Node | HTMLCollection | Node[] | string | null} newContent
2116
+ * @param {Config} [config]
2117
+ * @returns {undefined | Node[]}
2118
+ */
2119
+
2029
2120
  // base IIFE to define idiomorph
2121
+ /**
2122
+ *
2123
+ * @type {{defaults: ConfigInternal, morph: Morph}}
2124
+ */
2030
2125
  var Idiomorph = (function () {
2031
2126
 
2032
- //=============================================================================
2033
- // AND NOW IT BEGINS...
2034
- //=============================================================================
2035
- let EMPTY_SET = new Set();
2036
-
2037
- // default configuration values, updatable by users now
2038
- let defaults = {
2039
- morphStyle: "outerHTML",
2040
- callbacks : {
2041
- beforeNodeAdded: noOp,
2042
- afterNodeAdded: noOp,
2043
- beforeNodeMorphed: noOp,
2044
- afterNodeMorphed: noOp,
2045
- beforeNodeRemoved: noOp,
2046
- afterNodeRemoved: noOp,
2047
- beforeAttributeUpdated: noOp,
2048
-
2049
- },
2050
- head: {
2051
- style: 'merge',
2052
- shouldPreserve: function (elt) {
2053
- return elt.getAttribute("im-preserve") === "true";
2054
- },
2055
- shouldReAppend: function (elt) {
2056
- return elt.getAttribute("im-re-append") === "true";
2057
- },
2058
- shouldRemove: noOp,
2059
- afterHeadMorphed: noOp,
2060
- }
2061
- };
2127
+ /**
2128
+ * @typedef {object} MorphContext
2129
+ *
2130
+ * @property {Element} target
2131
+ * @property {Element} newContent
2132
+ * @property {ConfigInternal} config
2133
+ * @property {ConfigInternal['morphStyle']} morphStyle
2134
+ * @property {ConfigInternal['ignoreActive']} ignoreActive
2135
+ * @property {ConfigInternal['ignoreActiveValue']} ignoreActiveValue
2136
+ * @property {ConfigInternal['restoreFocus']} restoreFocus
2137
+ * @property {Map<Node, Set<string>>} idMap
2138
+ * @property {Set<string>} persistentIds
2139
+ * @property {ConfigInternal['callbacks']} callbacks
2140
+ * @property {ConfigInternal['head']} head
2141
+ * @property {HTMLDivElement} pantry
2142
+ */
2062
2143
 
2063
- //=============================================================================
2064
- // Core Morphing Algorithm - morph, morphNormalizedContent, morphOldNodeTo, morphChildren
2065
- //=============================================================================
2066
- function morph(oldNode, newContent, config = {}) {
2144
+ //=============================================================================
2145
+ // AND NOW IT BEGINS...
2146
+ //=============================================================================
2067
2147
 
2068
- if (oldNode instanceof Document) {
2069
- oldNode = oldNode.documentElement;
2070
- }
2148
+ const noOp = () => {};
2149
+ /**
2150
+ * Default configuration values, updatable by users now
2151
+ * @type {ConfigInternal}
2152
+ */
2153
+ const defaults = {
2154
+ morphStyle: "outerHTML",
2155
+ callbacks: {
2156
+ beforeNodeAdded: noOp,
2157
+ afterNodeAdded: noOp,
2158
+ beforeNodeMorphed: noOp,
2159
+ afterNodeMorphed: noOp,
2160
+ beforeNodeRemoved: noOp,
2161
+ afterNodeRemoved: noOp,
2162
+ beforeAttributeUpdated: noOp,
2163
+ },
2164
+ head: {
2165
+ style: "merge",
2166
+ shouldPreserve: (elt) => elt.getAttribute("im-preserve") === "true",
2167
+ shouldReAppend: (elt) => elt.getAttribute("im-re-append") === "true",
2168
+ shouldRemove: noOp,
2169
+ afterHeadMorphed: noOp,
2170
+ },
2171
+ restoreFocus: true,
2172
+ };
2071
2173
 
2072
- if (typeof newContent === 'string') {
2073
- newContent = parseContent(newContent);
2074
- }
2174
+ /**
2175
+ * Core idiomorph function for morphing one DOM tree to another
2176
+ *
2177
+ * @param {Element | Document} oldNode
2178
+ * @param {Element | Node | HTMLCollection | Node[] | string | null} newContent
2179
+ * @param {Config} [config]
2180
+ * @returns {Promise<Node[]> | Node[]}
2181
+ */
2182
+ function morph(oldNode, newContent, config = {}) {
2183
+ oldNode = normalizeElement(oldNode);
2184
+ const newNode = normalizeParent(newContent);
2185
+ const ctx = createMorphContext(oldNode, newNode, config);
2186
+
2187
+ const morphedNodes = saveAndRestoreFocus(ctx, () => {
2188
+ return withHeadBlocking(
2189
+ ctx,
2190
+ oldNode,
2191
+ newNode,
2192
+ /** @param {MorphContext} ctx */ (ctx) => {
2193
+ if (ctx.morphStyle === "innerHTML") {
2194
+ morphChildren(ctx, oldNode, newNode);
2195
+ return Array.from(oldNode.childNodes);
2196
+ } else {
2197
+ return morphOuterHTML(ctx, oldNode, newNode);
2198
+ }
2199
+ },
2200
+ );
2201
+ });
2075
2202
 
2076
- let normalizedContent = normalizeContent(newContent);
2203
+ ctx.pantry.remove();
2204
+ return morphedNodes;
2205
+ }
2077
2206
 
2078
- let ctx = createMorphContext(oldNode, normalizedContent, config);
2207
+ /**
2208
+ * Morph just the outerHTML of the oldNode to the newContent
2209
+ * We have to be careful because the oldNode could have siblings which need to be untouched
2210
+ * @param {MorphContext} ctx
2211
+ * @param {Element} oldNode
2212
+ * @param {Element} newNode
2213
+ * @returns {Node[]}
2214
+ */
2215
+ function morphOuterHTML(ctx, oldNode, newNode) {
2216
+ const oldParent = normalizeParent(oldNode);
2217
+
2218
+ // basis for calulating which nodes were morphed
2219
+ // since there may be unmorphed sibling nodes
2220
+ let childNodes = Array.from(oldParent.childNodes);
2221
+ const index = childNodes.indexOf(oldNode);
2222
+ // how many elements are to the right of the oldNode
2223
+ const rightMargin = childNodes.length - (index + 1);
2224
+
2225
+ morphChildren(
2226
+ ctx,
2227
+ oldParent,
2228
+ newNode,
2229
+ // these two optional params are the secret sauce
2230
+ oldNode, // start point for iteration
2231
+ oldNode.nextSibling, // end point for iteration
2232
+ );
2079
2233
 
2080
- return morphNormalizedContent(oldNode, normalizedContent, ctx);
2081
- }
2234
+ // return just the morphed nodes
2235
+ childNodes = Array.from(oldParent.childNodes);
2236
+ return childNodes.slice(index, childNodes.length - rightMargin);
2237
+ }
2082
2238
 
2083
- function morphNormalizedContent(oldNode, normalizedNewContent, ctx) {
2084
- if (ctx.head.block) {
2085
- let oldHead = oldNode.querySelector('head');
2086
- let newHead = normalizedNewContent.querySelector('head');
2087
- if (oldHead && newHead) {
2088
- let promises = handleHeadElement(newHead, oldHead, ctx);
2089
- // when head promises resolve, call morph again, ignoring the head tag
2090
- Promise.all(promises).then(function () {
2091
- morphNormalizedContent(oldNode, normalizedNewContent, Object.assign(ctx, {
2092
- head: {
2093
- block: false,
2094
- ignore: true
2095
- }
2096
- }));
2097
- });
2098
- return;
2099
- }
2100
- }
2239
+ /**
2240
+ * @param {MorphContext} ctx
2241
+ * @param {Function} fn
2242
+ * @returns {Promise<Node[]> | Node[]}
2243
+ */
2244
+ function saveAndRestoreFocus(ctx, fn) {
2245
+ if (!ctx.config.restoreFocus) return fn();
2246
+ let activeElement =
2247
+ /** @type {HTMLInputElement|HTMLTextAreaElement|null} */ (
2248
+ document.activeElement
2249
+ );
2101
2250
 
2102
- if (ctx.morphStyle === "innerHTML") {
2103
-
2104
- // innerHTML, so we are only updating the children
2105
- morphChildren(normalizedNewContent, oldNode, ctx);
2106
- return oldNode.children;
2107
-
2108
- } else if (ctx.morphStyle === "outerHTML" || ctx.morphStyle == null) {
2109
- // otherwise find the best element match in the new content, morph that, and merge its siblings
2110
- // into either side of the best match
2111
- let bestMatch = findBestNodeMatch(normalizedNewContent, oldNode, ctx);
2112
-
2113
- // stash the siblings that will need to be inserted on either side of the best match
2114
- let previousSibling = bestMatch?.previousSibling;
2115
- let nextSibling = bestMatch?.nextSibling;
2116
-
2117
- // morph it
2118
- let morphedNode = morphOldNodeTo(oldNode, bestMatch, ctx);
2119
-
2120
- if (bestMatch) {
2121
- // if there was a best match, merge the siblings in too and return the
2122
- // whole bunch
2123
- return insertSiblings(previousSibling, morphedNode, nextSibling);
2124
- } else {
2125
- // otherwise nothing was added to the DOM
2126
- return []
2127
- }
2128
- } else {
2129
- throw "Do not understand how to morph style " + ctx.morphStyle;
2251
+ // don't bother if the active element is not an input or textarea
2252
+ if (
2253
+ !(
2254
+ activeElement instanceof HTMLInputElement ||
2255
+ activeElement instanceof HTMLTextAreaElement
2256
+ )
2257
+ ) {
2258
+ return fn();
2259
+ }
2260
+
2261
+ const { id: activeElementId, selectionStart, selectionEnd } = activeElement;
2262
+
2263
+ const results = fn();
2264
+
2265
+ if (activeElementId && activeElementId !== document.activeElement?.id) {
2266
+ activeElement = ctx.target.querySelector(`#${activeElementId}`);
2267
+ activeElement?.focus();
2268
+ }
2269
+ if (activeElement && !activeElement.selectionEnd && selectionEnd) {
2270
+ activeElement.setSelectionRange(selectionStart, selectionEnd);
2271
+ }
2272
+
2273
+ return results;
2274
+ }
2275
+
2276
+ const morphChildren = (function () {
2277
+ /**
2278
+ * This is the core algorithm for matching up children. The idea is to use id sets to try to match up
2279
+ * nodes as faithfully as possible. We greedily match, which allows us to keep the algorithm fast, but
2280
+ * by using id sets, we are able to better match up with content deeper in the DOM.
2281
+ *
2282
+ * Basic algorithm:
2283
+ * - for each node in the new content:
2284
+ * - search self and siblings for an id set match, falling back to a soft match
2285
+ * - if match found
2286
+ * - remove any nodes up to the match:
2287
+ * - pantry persistent nodes
2288
+ * - delete the rest
2289
+ * - morph the match
2290
+ * - elsif no match found, and node is persistent
2291
+ * - find its match by querying the old root (future) and pantry (past)
2292
+ * - move it and its children here
2293
+ * - morph it
2294
+ * - else
2295
+ * - create a new node from scratch as a last result
2296
+ *
2297
+ * @param {MorphContext} ctx the merge context
2298
+ * @param {Element} oldParent the old content that we are merging the new content into
2299
+ * @param {Element} newParent the parent element of the new content
2300
+ * @param {Node|null} [insertionPoint] the point in the DOM we start morphing at (defaults to first child)
2301
+ * @param {Node|null} [endPoint] the point in the DOM we stop morphing at (defaults to after last child)
2302
+ */
2303
+ function morphChildren(
2304
+ ctx,
2305
+ oldParent,
2306
+ newParent,
2307
+ insertionPoint = null,
2308
+ endPoint = null,
2309
+ ) {
2310
+ // normalize
2311
+ if (
2312
+ oldParent instanceof HTMLTemplateElement &&
2313
+ newParent instanceof HTMLTemplateElement
2314
+ ) {
2315
+ // @ts-ignore we can pretend the DocumentFragment is an Element
2316
+ oldParent = oldParent.content;
2317
+ // @ts-ignore ditto
2318
+ newParent = newParent.content;
2319
+ }
2320
+ insertionPoint ||= oldParent.firstChild;
2321
+
2322
+ // run through all the new content
2323
+ for (const newChild of newParent.childNodes) {
2324
+ // once we reach the end of the old parent content skip to the end and insert the rest
2325
+ if (insertionPoint && insertionPoint != endPoint) {
2326
+ const bestMatch = findBestMatch(
2327
+ ctx,
2328
+ newChild,
2329
+ insertionPoint,
2330
+ endPoint,
2331
+ );
2332
+ if (bestMatch) {
2333
+ // if the node to morph is not at the insertion point then remove/move up to it
2334
+ if (bestMatch !== insertionPoint) {
2335
+ removeNodesBetween(ctx, insertionPoint, bestMatch);
2130
2336
  }
2337
+ morphNode(bestMatch, newChild, ctx);
2338
+ insertionPoint = bestMatch.nextSibling;
2339
+ continue;
2340
+ }
2131
2341
  }
2132
2342
 
2133
-
2134
- /**
2135
- * @param possibleActiveElement
2136
- * @param ctx
2137
- * @returns {boolean}
2138
- */
2139
- function ignoreValueOfActiveElement(possibleActiveElement, ctx) {
2140
- return ctx.ignoreActiveValue && possibleActiveElement === document.activeElement && possibleActiveElement !== document.body;
2343
+ // if the matching node is elsewhere in the original content
2344
+ if (newChild instanceof Element && ctx.persistentIds.has(newChild.id)) {
2345
+ // move it and all its children here and morph
2346
+ const movedChild = moveBeforeById(
2347
+ oldParent,
2348
+ newChild.id,
2349
+ insertionPoint,
2350
+ ctx,
2351
+ );
2352
+ morphNode(movedChild, newChild, ctx);
2353
+ insertionPoint = movedChild.nextSibling;
2354
+ continue;
2141
2355
  }
2142
2356
 
2143
- /**
2144
- * @param oldNode root node to merge content into
2145
- * @param newContent new content to merge
2146
- * @param ctx the merge context
2147
- * @returns {Element} the element that ended up in the DOM
2148
- */
2149
- function morphOldNodeTo(oldNode, newContent, ctx) {
2150
- if (ctx.ignoreActive && oldNode === document.activeElement) ; else if (newContent == null) {
2151
- if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode;
2152
-
2153
- oldNode.remove();
2154
- ctx.callbacks.afterNodeRemoved(oldNode);
2155
- return null;
2156
- } else if (!isSoftMatch(oldNode, newContent)) {
2157
- if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode;
2158
- if (ctx.callbacks.beforeNodeAdded(newContent) === false) return oldNode;
2159
-
2160
- oldNode.parentElement.replaceChild(newContent, oldNode);
2161
- ctx.callbacks.afterNodeAdded(newContent);
2162
- ctx.callbacks.afterNodeRemoved(oldNode);
2163
- return newContent;
2164
- } else {
2165
- if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) return oldNode;
2166
-
2167
- if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) ; else if (oldNode instanceof HTMLHeadElement && ctx.head.style !== "morph") {
2168
- handleHeadElement(newContent, oldNode, ctx);
2169
- } else {
2170
- syncNodeFrom(newContent, oldNode, ctx);
2171
- if (!ignoreValueOfActiveElement(oldNode, ctx)) {
2172
- morphChildren(newContent, oldNode, ctx);
2173
- }
2174
- }
2175
- ctx.callbacks.afterNodeMorphed(oldNode, newContent);
2176
- return oldNode;
2177
- }
2357
+ // last resort: insert the new node from scratch
2358
+ const insertedNode = createNode(
2359
+ oldParent,
2360
+ newChild,
2361
+ insertionPoint,
2362
+ ctx,
2363
+ );
2364
+ // could be null if beforeNodeAdded prevented insertion
2365
+ if (insertedNode) {
2366
+ insertionPoint = insertedNode.nextSibling;
2178
2367
  }
2368
+ }
2179
2369
 
2180
- /**
2181
- * This is the core algorithm for matching up children. The idea is to use id sets to try to match up
2182
- * nodes as faithfully as possible. We greedily match, which allows us to keep the algorithm fast, but
2183
- * by using id sets, we are able to better match up with content deeper in the DOM.
2184
- *
2185
- * Basic algorithm is, for each node in the new content:
2186
- *
2187
- * - if we have reached the end of the old parent, append the new content
2188
- * - if the new content has an id set match with the current insertion point, morph
2189
- * - search for an id set match
2190
- * - if id set match found, morph
2191
- * - otherwise search for a "soft" match
2192
- * - if a soft match is found, morph
2193
- * - otherwise, prepend the new node before the current insertion point
2194
- *
2195
- * The two search algorithms terminate if competing node matches appear to outweigh what can be achieved
2196
- * with the current node. See findIdSetMatch() and findSoftMatch() for details.
2197
- *
2198
- * @param {Element} newParent the parent element of the new content
2199
- * @param {Element } oldParent the old content that we are merging the new content into
2200
- * @param ctx the merge context
2201
- */
2202
- function morphChildren(newParent, oldParent, ctx) {
2203
-
2204
- let nextNewChild = newParent.firstChild;
2205
- let insertionPoint = oldParent.firstChild;
2206
- let newChild;
2207
-
2208
- // run through all the new content
2209
- while (nextNewChild) {
2210
-
2211
- newChild = nextNewChild;
2212
- nextNewChild = newChild.nextSibling;
2213
-
2214
- // if we are at the end of the exiting parent's children, just append
2215
- if (insertionPoint == null) {
2216
- if (ctx.callbacks.beforeNodeAdded(newChild) === false) return;
2217
-
2218
- oldParent.appendChild(newChild);
2219
- ctx.callbacks.afterNodeAdded(newChild);
2220
- removeIdsFromConsideration(ctx, newChild);
2221
- continue;
2222
- }
2223
-
2224
- // if the current node has an id set match then morph
2225
- if (isIdSetMatch(newChild, insertionPoint, ctx)) {
2226
- morphOldNodeTo(insertionPoint, newChild, ctx);
2227
- insertionPoint = insertionPoint.nextSibling;
2228
- removeIdsFromConsideration(ctx, newChild);
2229
- continue;
2230
- }
2231
-
2232
- // otherwise search forward in the existing old children for an id set match
2233
- let idSetMatch = findIdSetMatch(newParent, oldParent, newChild, insertionPoint, ctx);
2234
-
2235
- // if we found a potential match, remove the nodes until that point and morph
2236
- if (idSetMatch) {
2237
- insertionPoint = removeNodesBetween(insertionPoint, idSetMatch, ctx);
2238
- morphOldNodeTo(idSetMatch, newChild, ctx);
2239
- removeIdsFromConsideration(ctx, newChild);
2240
- continue;
2241
- }
2242
-
2243
- // no id set match found, so scan forward for a soft match for the current node
2244
- let softMatch = findSoftMatch(newParent, oldParent, newChild, insertionPoint, ctx);
2245
-
2246
- // if we found a soft match for the current node, morph
2247
- if (softMatch) {
2248
- insertionPoint = removeNodesBetween(insertionPoint, softMatch, ctx);
2249
- morphOldNodeTo(softMatch, newChild, ctx);
2250
- removeIdsFromConsideration(ctx, newChild);
2251
- continue;
2252
- }
2253
-
2254
- // abandon all hope of morphing, just insert the new child before the insertion point
2255
- // and move on
2256
- if (ctx.callbacks.beforeNodeAdded(newChild) === false) return;
2257
-
2258
- oldParent.insertBefore(newChild, insertionPoint);
2259
- ctx.callbacks.afterNodeAdded(newChild);
2260
- removeIdsFromConsideration(ctx, newChild);
2261
- }
2370
+ // remove any remaining old nodes that didn't match up with new content
2371
+ while (insertionPoint && insertionPoint != endPoint) {
2372
+ const tempNode = insertionPoint;
2373
+ insertionPoint = insertionPoint.nextSibling;
2374
+ removeNode(ctx, tempNode);
2375
+ }
2376
+ }
2262
2377
 
2263
- // remove any remaining old nodes that didn't match up with new content
2264
- while (insertionPoint !== null) {
2378
+ /**
2379
+ * This performs the action of inserting a new node while handling situations where the node contains
2380
+ * elements with persistent ids and possible state info we can still preserve by moving in and then morphing
2381
+ *
2382
+ * @param {Element} oldParent
2383
+ * @param {Node} newChild
2384
+ * @param {Node|null} insertionPoint
2385
+ * @param {MorphContext} ctx
2386
+ * @returns {Node|null}
2387
+ */
2388
+ function createNode(oldParent, newChild, insertionPoint, ctx) {
2389
+ if (ctx.callbacks.beforeNodeAdded(newChild) === false) return null;
2390
+ if (ctx.idMap.has(newChild)) {
2391
+ // node has children with ids with possible state so create a dummy elt of same type and apply full morph algorithm
2392
+ const newEmptyChild = document.createElement(
2393
+ /** @type {Element} */ (newChild).tagName,
2394
+ );
2395
+ oldParent.insertBefore(newEmptyChild, insertionPoint);
2396
+ morphNode(newEmptyChild, newChild, ctx);
2397
+ ctx.callbacks.afterNodeAdded(newEmptyChild);
2398
+ return newEmptyChild;
2399
+ } else {
2400
+ // optimisation: no id state to preserve so we can just insert a clone of the newChild and its descendants
2401
+ const newClonedChild = document.importNode(newChild, true); // importNode to not mutate newParent
2402
+ oldParent.insertBefore(newClonedChild, insertionPoint);
2403
+ ctx.callbacks.afterNodeAdded(newClonedChild);
2404
+ return newClonedChild;
2405
+ }
2406
+ }
2265
2407
 
2266
- let tempNode = insertionPoint;
2267
- insertionPoint = insertionPoint.nextSibling;
2268
- removeNode(tempNode, ctx);
2408
+ //=============================================================================
2409
+ // Matching Functions
2410
+ //=============================================================================
2411
+ const findBestMatch = (function () {
2412
+ /**
2413
+ * Scans forward from the startPoint to the endPoint looking for a match
2414
+ * for the node. It looks for an id set match first, then a soft match.
2415
+ * We abort softmatching if we find two future soft matches, to reduce churn.
2416
+ * @param {Node} node
2417
+ * @param {MorphContext} ctx
2418
+ * @param {Node | null} startPoint
2419
+ * @param {Node | null} endPoint
2420
+ * @returns {Node | null}
2421
+ */
2422
+ function findBestMatch(ctx, node, startPoint, endPoint) {
2423
+ let softMatch = null;
2424
+ let nextSibling = node.nextSibling;
2425
+ let siblingSoftMatchCount = 0;
2426
+
2427
+ let cursor = startPoint;
2428
+ while (cursor && cursor != endPoint) {
2429
+ // soft matching is a prerequisite for id set matching
2430
+ if (isSoftMatch(cursor, node)) {
2431
+ if (isIdSetMatch(ctx, cursor, node)) {
2432
+ return cursor; // found an id set match, we're done!
2269
2433
  }
2270
- }
2271
2434
 
2272
- //=============================================================================
2273
- // Attribute Syncing Code
2274
- //=============================================================================
2275
-
2276
- /**
2277
- * @param attr {String} the attribute to be mutated
2278
- * @param to {Element} the element that is going to be updated
2279
- * @param updateType {("update"|"remove")}
2280
- * @param ctx the merge context
2281
- * @returns {boolean} true if the attribute should be ignored, false otherwise
2282
- */
2283
- function ignoreAttribute(attr, to, updateType, ctx) {
2284
- if(attr === 'value' && ctx.ignoreActiveValue && to === document.activeElement){
2285
- return true;
2435
+ // we haven't yet saved a soft match fallback
2436
+ if (softMatch === null) {
2437
+ // the current soft match will hard match something else in the future, leave it
2438
+ if (!ctx.idMap.has(cursor)) {
2439
+ // save this as the fallback if we get through the loop without finding a hard match
2440
+ softMatch = cursor;
2441
+ }
2286
2442
  }
2287
- return ctx.callbacks.beforeAttributeUpdated(attr, to, updateType) === false;
2288
- }
2289
-
2290
- /**
2291
- * syncs a given node with another node, copying over all attributes and
2292
- * inner element state from the 'from' node to the 'to' node
2293
- *
2294
- * @param {Element} from the element to copy attributes & state from
2295
- * @param {Element} to the element to copy attributes & state to
2296
- * @param ctx the merge context
2297
- */
2298
- function syncNodeFrom(from, to, ctx) {
2299
- let type = from.nodeType;
2300
-
2301
- // if is an element type, sync the attributes from the
2302
- // new node into the new node
2303
- if (type === 1 /* element type */) {
2304
- const fromAttributes = from.attributes;
2305
- const toAttributes = to.attributes;
2306
- for (const fromAttribute of fromAttributes) {
2307
- if (ignoreAttribute(fromAttribute.name, to, 'update', ctx)) {
2308
- continue;
2309
- }
2310
- if (to.getAttribute(fromAttribute.name) !== fromAttribute.value) {
2311
- to.setAttribute(fromAttribute.name, fromAttribute.value);
2312
- }
2313
- }
2314
- // iterate backwards to avoid skipping over items when a delete occurs
2315
- for (let i = toAttributes.length - 1; 0 <= i; i--) {
2316
- const toAttribute = toAttributes[i];
2317
- if (ignoreAttribute(toAttribute.name, to, 'remove', ctx)) {
2318
- continue;
2319
- }
2320
- if (!from.hasAttribute(toAttribute.name)) {
2321
- to.removeAttribute(toAttribute.name);
2322
- }
2323
- }
2443
+ }
2444
+ if (
2445
+ softMatch === null &&
2446
+ nextSibling &&
2447
+ isSoftMatch(cursor, nextSibling)
2448
+ ) {
2449
+ // The next new node has a soft match with this node, so
2450
+ // increment the count of future soft matches
2451
+ siblingSoftMatchCount++;
2452
+ nextSibling = nextSibling.nextSibling;
2453
+
2454
+ // If there are two future soft matches, block soft matching for this node to allow
2455
+ // future siblings to soft match. This is to reduce churn in the DOM when an element
2456
+ // is prepended.
2457
+ if (siblingSoftMatchCount >= 2) {
2458
+ softMatch = undefined;
2324
2459
  }
2460
+ }
2325
2461
 
2326
- // sync text nodes
2327
- if (type === 8 /* comment */ || type === 3 /* text */) {
2328
- if (to.nodeValue !== from.nodeValue) {
2329
- to.nodeValue = from.nodeValue;
2330
- }
2331
- }
2462
+ // if the current node contains active element, stop looking for better future matches,
2463
+ // because if one is found, this node will be moved to the pantry, reparenting it and thus losing focus
2464
+ if (cursor.contains(document.activeElement)) break;
2332
2465
 
2333
- if (!ignoreValueOfActiveElement(to, ctx)) {
2334
- // sync input values
2335
- syncInputValue(from, to, ctx);
2336
- }
2466
+ cursor = cursor.nextSibling;
2337
2467
  }
2338
2468
 
2339
- /**
2340
- * @param from {Element} element to sync the value from
2341
- * @param to {Element} element to sync the value to
2342
- * @param attributeName {String} the attribute name
2343
- * @param ctx the merge context
2344
- */
2345
- function syncBooleanAttribute(from, to, attributeName, ctx) {
2346
- if (from[attributeName] !== to[attributeName]) {
2347
- let ignoreUpdate = ignoreAttribute(attributeName, to, 'update', ctx);
2348
- if (!ignoreUpdate) {
2349
- to[attributeName] = from[attributeName];
2350
- }
2351
- if (from[attributeName]) {
2352
- if (!ignoreUpdate) {
2353
- to.setAttribute(attributeName, from[attributeName]);
2354
- }
2355
- } else {
2356
- if (!ignoreAttribute(attributeName, to, 'remove', ctx)) {
2357
- to.removeAttribute(attributeName);
2358
- }
2359
- }
2360
- }
2361
- }
2469
+ return softMatch || null;
2470
+ }
2362
2471
 
2363
- /**
2364
- * NB: many bothans died to bring us information:
2365
- *
2366
- * https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js
2367
- * https://github.com/choojs/nanomorph/blob/master/lib/morph.jsL113
2368
- *
2369
- * @param from {Element} the element to sync the input value from
2370
- * @param to {Element} the element to sync the input value to
2371
- * @param ctx the merge context
2372
- */
2373
- function syncInputValue(from, to, ctx) {
2374
- if (from instanceof HTMLInputElement &&
2375
- to instanceof HTMLInputElement &&
2376
- from.type !== 'file') {
2377
-
2378
- let fromValue = from.value;
2379
- let toValue = to.value;
2380
-
2381
- // sync boolean attributes
2382
- syncBooleanAttribute(from, to, 'checked', ctx);
2383
- syncBooleanAttribute(from, to, 'disabled', ctx);
2384
-
2385
- if (!from.hasAttribute('value')) {
2386
- if (!ignoreAttribute('value', to, 'remove', ctx)) {
2387
- to.value = '';
2388
- to.removeAttribute('value');
2389
- }
2390
- } else if (fromValue !== toValue) {
2391
- if (!ignoreAttribute('value', to, 'update', ctx)) {
2392
- to.setAttribute('value', fromValue);
2393
- to.value = fromValue;
2394
- }
2395
- }
2396
- } else if (from instanceof HTMLOptionElement) {
2397
- syncBooleanAttribute(from, to, 'selected', ctx);
2398
- } else if (from instanceof HTMLTextAreaElement && to instanceof HTMLTextAreaElement) {
2399
- let fromValue = from.value;
2400
- let toValue = to.value;
2401
- if (ignoreAttribute('value', to, 'update', ctx)) {
2402
- return;
2403
- }
2404
- if (fromValue !== toValue) {
2405
- to.value = fromValue;
2406
- }
2407
- if (to.firstChild && to.firstChild.nodeValue !== fromValue) {
2408
- to.firstChild.nodeValue = fromValue;
2409
- }
2410
- }
2472
+ /**
2473
+ *
2474
+ * @param {MorphContext} ctx
2475
+ * @param {Node} oldNode
2476
+ * @param {Node} newNode
2477
+ * @returns {boolean}
2478
+ */
2479
+ function isIdSetMatch(ctx, oldNode, newNode) {
2480
+ let oldSet = ctx.idMap.get(oldNode);
2481
+ let newSet = ctx.idMap.get(newNode);
2482
+
2483
+ if (!newSet || !oldSet) return false;
2484
+
2485
+ for (const id of oldSet) {
2486
+ // a potential match is an id in the new and old nodes that
2487
+ // has not already been merged into the DOM
2488
+ // But the newNode content we call this on has not been
2489
+ // merged yet and we don't allow duplicate IDs so it is simple
2490
+ if (newSet.has(id)) {
2491
+ return true;
2492
+ }
2411
2493
  }
2494
+ return false;
2495
+ }
2412
2496
 
2413
- //=============================================================================
2414
- // the HEAD tag can be handled specially, either w/ a 'merge' or 'append' style
2415
- //=============================================================================
2416
- function handleHeadElement(newHeadTag, currentHead, ctx) {
2497
+ /**
2498
+ *
2499
+ * @param {Node} oldNode
2500
+ * @param {Node} newNode
2501
+ * @returns {boolean}
2502
+ */
2503
+ function isSoftMatch(oldNode, newNode) {
2504
+ // ok to cast: if one is not element, `id` and `tagName` will be undefined and we'll just compare that.
2505
+ const oldElt = /** @type {Element} */ (oldNode);
2506
+ const newElt = /** @type {Element} */ (newNode);
2507
+
2508
+ return (
2509
+ oldElt.nodeType === newElt.nodeType &&
2510
+ oldElt.tagName === newElt.tagName &&
2511
+ // If oldElt has an `id` with possible state and it doesn't match newElt.id then avoid morphing.
2512
+ // We'll still match an anonymous node with an IDed newElt, though, because if it got this far,
2513
+ // its not persistent, and new nodes can't have any hidden state.
2514
+ (!oldElt.id || oldElt.id === newElt.id)
2515
+ );
2516
+ }
2417
2517
 
2418
- let added = [];
2419
- let removed = [];
2420
- let preserved = [];
2421
- let nodesToAppend = [];
2518
+ return findBestMatch;
2519
+ })();
2422
2520
 
2423
- let headMergeStyle = ctx.head.style;
2521
+ //=============================================================================
2522
+ // DOM Manipulation Functions
2523
+ //=============================================================================
2524
+
2525
+ /**
2526
+ * Gets rid of an unwanted DOM node; strategy depends on nature of its reuse:
2527
+ * - Persistent nodes will be moved to the pantry for later reuse
2528
+ * - Other nodes will have their hooks called, and then are removed
2529
+ * @param {MorphContext} ctx
2530
+ * @param {Node} node
2531
+ */
2532
+ function removeNode(ctx, node) {
2533
+ // are we going to id set match this later?
2534
+ if (ctx.idMap.has(node)) {
2535
+ // skip callbacks and move to pantry
2536
+ moveBefore(ctx.pantry, node, null);
2537
+ } else {
2538
+ // remove for realsies
2539
+ if (ctx.callbacks.beforeNodeRemoved(node) === false) return;
2540
+ node.parentNode?.removeChild(node);
2541
+ ctx.callbacks.afterNodeRemoved(node);
2542
+ }
2543
+ }
2424
2544
 
2425
- // put all new head elements into a Map, by their outerHTML
2426
- let srcToNewHeadNodes = new Map();
2427
- for (const newHeadChild of newHeadTag.children) {
2428
- srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild);
2429
- }
2545
+ /**
2546
+ * Remove nodes between the start and end nodes
2547
+ * @param {MorphContext} ctx
2548
+ * @param {Node} startInclusive
2549
+ * @param {Node} endExclusive
2550
+ * @returns {Node|null}
2551
+ */
2552
+ function removeNodesBetween(ctx, startInclusive, endExclusive) {
2553
+ /** @type {Node | null} */
2554
+ let cursor = startInclusive;
2555
+ // remove nodes until the endExclusive node
2556
+ while (cursor && cursor !== endExclusive) {
2557
+ let tempNode = /** @type {Node} */ (cursor);
2558
+ cursor = cursor.nextSibling;
2559
+ removeNode(ctx, tempNode);
2560
+ }
2561
+ return cursor;
2562
+ }
2563
+
2564
+ /**
2565
+ * Search for an element by id within the document and pantry, and move it using moveBefore.
2566
+ *
2567
+ * @param {Element} parentNode - The parent node to which the element will be moved.
2568
+ * @param {string} id - The ID of the element to be moved.
2569
+ * @param {Node | null} after - The reference node to insert the element before.
2570
+ * If `null`, the element is appended as the last child.
2571
+ * @param {MorphContext} ctx
2572
+ * @returns {Element} The found element
2573
+ */
2574
+ function moveBeforeById(parentNode, id, after, ctx) {
2575
+ const target =
2576
+ /** @type {Element} - will always be found */
2577
+ (
2578
+ ctx.target.querySelector(`#${id}`) ||
2579
+ ctx.pantry.querySelector(`#${id}`)
2580
+ );
2581
+ removeElementFromAncestorsIdMaps(target, ctx);
2582
+ moveBefore(parentNode, target, after);
2583
+ return target;
2584
+ }
2585
+
2586
+ /**
2587
+ * Removes an element from its ancestors' id maps. This is needed when an element is moved from the
2588
+ * "future" via `moveBeforeId`. Otherwise, its erstwhile ancestors could be mistakenly moved to the
2589
+ * pantry rather than being deleted, preventing their removal hooks from being called.
2590
+ *
2591
+ * @param {Element} element - element to remove from its ancestors' id maps
2592
+ * @param {MorphContext} ctx
2593
+ */
2594
+ function removeElementFromAncestorsIdMaps(element, ctx) {
2595
+ const id = element.id;
2596
+ /** @ts-ignore - safe to loop in this way **/
2597
+ while ((element = element.parentNode)) {
2598
+ let idSet = ctx.idMap.get(element);
2599
+ if (idSet) {
2600
+ idSet.delete(id);
2601
+ if (!idSet.size) {
2602
+ ctx.idMap.delete(element);
2603
+ }
2604
+ }
2605
+ }
2606
+ }
2430
2607
 
2431
- // for each elt in the current head
2432
- for (const currentHeadElt of currentHead.children) {
2433
-
2434
- // If the current head element is in the map
2435
- let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML);
2436
- let isReAppended = ctx.head.shouldReAppend(currentHeadElt);
2437
- let isPreserved = ctx.head.shouldPreserve(currentHeadElt);
2438
- if (inNewContent || isPreserved) {
2439
- if (isReAppended) {
2440
- // remove the current version and let the new version replace it and re-execute
2441
- removed.push(currentHeadElt);
2442
- } else {
2443
- // this element already exists and should not be re-appended, so remove it from
2444
- // the new content map, preserving it in the DOM
2445
- srcToNewHeadNodes.delete(currentHeadElt.outerHTML);
2446
- preserved.push(currentHeadElt);
2447
- }
2448
- } else {
2449
- if (headMergeStyle === "append") {
2450
- // we are appending and this existing element is not new content
2451
- // so if and only if it is marked for re-append do we do anything
2452
- if (isReAppended) {
2453
- removed.push(currentHeadElt);
2454
- nodesToAppend.push(currentHeadElt);
2455
- }
2456
- } else {
2457
- // if this is a merge, we remove this content since it is not in the new head
2458
- if (ctx.head.shouldRemove(currentHeadElt) !== false) {
2459
- removed.push(currentHeadElt);
2460
- }
2461
- }
2462
- }
2463
- }
2608
+ /**
2609
+ * Moves an element before another element within the same parent.
2610
+ * Uses the proposed `moveBefore` API if available (and working), otherwise falls back to `insertBefore`.
2611
+ * This is essentialy a forward-compat wrapper.
2612
+ *
2613
+ * @param {Element} parentNode - The parent node containing the after element.
2614
+ * @param {Node} element - The element to be moved.
2615
+ * @param {Node | null} after - The reference node to insert `element` before.
2616
+ * If `null`, `element` is appended as the last child.
2617
+ */
2618
+ function moveBefore(parentNode, element, after) {
2619
+ // @ts-ignore - use proposed moveBefore feature
2620
+ if (parentNode.moveBefore) {
2621
+ try {
2622
+ // @ts-ignore - use proposed moveBefore feature
2623
+ parentNode.moveBefore(element, after);
2624
+ } catch (e) {
2625
+ // fall back to insertBefore as some browsers may fail on moveBefore when trying to move Dom disconnected nodes to pantry
2626
+ parentNode.insertBefore(element, after);
2627
+ }
2628
+ } else {
2629
+ parentNode.insertBefore(element, after);
2630
+ }
2631
+ }
2464
2632
 
2465
- // Push the remaining new head elements in the Map into the
2466
- // nodes to append to the head tag
2467
- nodesToAppend.push(...srcToNewHeadNodes.values());
2468
-
2469
- let promises = [];
2470
- for (const newNode of nodesToAppend) {
2471
- let newElt = document.createRange().createContextualFragment(newNode.outerHTML).firstChild;
2472
- if (ctx.callbacks.beforeNodeAdded(newElt) !== false) {
2473
- if (newElt.href || newElt.src) {
2474
- let resolve = null;
2475
- let promise = new Promise(function (_resolve) {
2476
- resolve = _resolve;
2477
- });
2478
- newElt.addEventListener('load', function () {
2479
- resolve();
2480
- });
2481
- promises.push(promise);
2482
- }
2483
- currentHead.appendChild(newElt);
2484
- ctx.callbacks.afterNodeAdded(newElt);
2485
- added.push(newElt);
2486
- }
2487
- }
2633
+ return morphChildren;
2634
+ })();
2635
+
2636
+ //=============================================================================
2637
+ // Single Node Morphing Code
2638
+ //=============================================================================
2639
+ const morphNode = (function () {
2640
+ /**
2641
+ * @param {Node} oldNode root node to merge content into
2642
+ * @param {Node} newContent new content to merge
2643
+ * @param {MorphContext} ctx the merge context
2644
+ * @returns {Node | null} the element that ended up in the DOM
2645
+ */
2646
+ function morphNode(oldNode, newContent, ctx) {
2647
+ if (ctx.ignoreActive && oldNode === document.activeElement) {
2648
+ // don't morph focused element
2649
+ return null;
2650
+ }
2488
2651
 
2489
- // remove all removed elements, after we have appended the new elements to avoid
2490
- // additional network requests for things like style sheets
2491
- for (const removedElement of removed) {
2492
- if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) {
2493
- currentHead.removeChild(removedElement);
2494
- ctx.callbacks.afterNodeRemoved(removedElement);
2495
- }
2496
- }
2652
+ if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) {
2653
+ return oldNode;
2654
+ }
2497
2655
 
2498
- ctx.head.afterHeadMorphed(currentHead, {added: added, kept: preserved, removed: removed});
2499
- return promises;
2656
+ if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) ; else if (
2657
+ oldNode instanceof HTMLHeadElement &&
2658
+ ctx.head.style !== "morph"
2659
+ ) {
2660
+ // ok to cast: if newContent wasn't also a <head>, it would've got caught in the `!isSoftMatch` branch above
2661
+ handleHeadElement(
2662
+ oldNode,
2663
+ /** @type {HTMLHeadElement} */ (newContent),
2664
+ ctx,
2665
+ );
2666
+ } else {
2667
+ morphAttributes(oldNode, newContent, ctx);
2668
+ if (!ignoreValueOfActiveElement(oldNode, ctx)) {
2669
+ // @ts-ignore newContent can be a node here because .firstChild will be null
2670
+ morphChildren(ctx, oldNode, newContent);
2500
2671
  }
2501
-
2502
- function noOp() {
2672
+ }
2673
+ ctx.callbacks.afterNodeMorphed(oldNode, newContent);
2674
+ return oldNode;
2675
+ }
2676
+
2677
+ /**
2678
+ * syncs the oldNode to the newNode, copying over all attributes and
2679
+ * inner element state from the newNode to the oldNode
2680
+ *
2681
+ * @param {Node} oldNode the node to copy attributes & state to
2682
+ * @param {Node} newNode the node to copy attributes & state from
2683
+ * @param {MorphContext} ctx the merge context
2684
+ */
2685
+ function morphAttributes(oldNode, newNode, ctx) {
2686
+ let type = newNode.nodeType;
2687
+
2688
+ // if is an element type, sync the attributes from the
2689
+ // new node into the new node
2690
+ if (type === 1 /* element type */) {
2691
+ const oldElt = /** @type {Element} */ (oldNode);
2692
+ const newElt = /** @type {Element} */ (newNode);
2693
+
2694
+ const oldAttributes = oldElt.attributes;
2695
+ const newAttributes = newElt.attributes;
2696
+ for (const newAttribute of newAttributes) {
2697
+ if (ignoreAttribute(newAttribute.name, oldElt, "update", ctx)) {
2698
+ continue;
2699
+ }
2700
+ if (oldElt.getAttribute(newAttribute.name) !== newAttribute.value) {
2701
+ oldElt.setAttribute(newAttribute.name, newAttribute.value);
2702
+ }
2503
2703
  }
2704
+ // iterate backwards to avoid skipping over items when a delete occurs
2705
+ for (let i = oldAttributes.length - 1; 0 <= i; i--) {
2706
+ const oldAttribute = oldAttributes[i];
2504
2707
 
2505
- /*
2506
- Deep merges the config object and the Idiomoroph.defaults object to
2507
- produce a final configuration object
2508
- */
2509
- function mergeDefaults(config) {
2510
- let finalConfig = {};
2511
- // copy top level stuff into final config
2512
- Object.assign(finalConfig, defaults);
2513
- Object.assign(finalConfig, config);
2514
-
2515
- // copy callbacks into final config (do this to deep merge the callbacks)
2516
- finalConfig.callbacks = {};
2517
- Object.assign(finalConfig.callbacks, defaults.callbacks);
2518
- Object.assign(finalConfig.callbacks, config.callbacks);
2519
-
2520
- // copy head config into final config (do this to deep merge the head)
2521
- finalConfig.head = {};
2522
- Object.assign(finalConfig.head, defaults.head);
2523
- Object.assign(finalConfig.head, config.head);
2524
- return finalConfig;
2525
- }
2708
+ // toAttributes is a live NamedNodeMap, so iteration+mutation is unsafe
2709
+ // e.g. custom element attribute callbacks can remove other attributes
2710
+ if (!oldAttribute) continue;
2526
2711
 
2527
- function createMorphContext(oldNode, newContent, config) {
2528
- config = mergeDefaults(config);
2529
- return {
2530
- target: oldNode,
2531
- newContent: newContent,
2532
- config: config,
2533
- morphStyle: config.morphStyle,
2534
- ignoreActive: config.ignoreActive,
2535
- ignoreActiveValue: config.ignoreActiveValue,
2536
- idMap: createIdMap(oldNode, newContent),
2537
- deadIds: new Set(),
2538
- callbacks: config.callbacks,
2539
- head: config.head
2712
+ if (!newElt.hasAttribute(oldAttribute.name)) {
2713
+ if (ignoreAttribute(oldAttribute.name, oldElt, "remove", ctx)) {
2714
+ continue;
2540
2715
  }
2716
+ oldElt.removeAttribute(oldAttribute.name);
2717
+ }
2541
2718
  }
2542
2719
 
2543
- function isIdSetMatch(node1, node2, ctx) {
2544
- if (node1 == null || node2 == null) {
2545
- return false;
2546
- }
2547
- if (node1.nodeType === node2.nodeType && node1.tagName === node2.tagName) {
2548
- if (node1.id !== "" && node1.id === node2.id) {
2549
- return true;
2550
- } else {
2551
- return getIdIntersectionCount(ctx, node1, node2) > 0;
2552
- }
2553
- }
2554
- return false;
2720
+ if (!ignoreValueOfActiveElement(oldElt, ctx)) {
2721
+ syncInputValue(oldElt, newElt, ctx);
2555
2722
  }
2723
+ }
2556
2724
 
2557
- function isSoftMatch(node1, node2) {
2558
- if (node1 == null || node2 == null) {
2559
- return false;
2560
- }
2561
- return node1.nodeType === node2.nodeType && node1.tagName === node2.tagName
2725
+ // sync text nodes
2726
+ if (type === 8 /* comment */ || type === 3 /* text */) {
2727
+ if (oldNode.nodeValue !== newNode.nodeValue) {
2728
+ oldNode.nodeValue = newNode.nodeValue;
2562
2729
  }
2730
+ }
2731
+ }
2563
2732
 
2564
- function removeNodesBetween(startInclusive, endExclusive, ctx) {
2565
- while (startInclusive !== endExclusive) {
2566
- let tempNode = startInclusive;
2567
- startInclusive = startInclusive.nextSibling;
2568
- removeNode(tempNode, ctx);
2569
- }
2570
- removeIdsFromConsideration(ctx, endExclusive);
2571
- return endExclusive.nextSibling;
2733
+ /**
2734
+ * NB: many bothans died to bring us information:
2735
+ *
2736
+ * https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js
2737
+ * https://github.com/choojs/nanomorph/blob/master/lib/morph.jsL113
2738
+ *
2739
+ * @param {Element} oldElement the element to sync the input value to
2740
+ * @param {Element} newElement the element to sync the input value from
2741
+ * @param {MorphContext} ctx the merge context
2742
+ */
2743
+ function syncInputValue(oldElement, newElement, ctx) {
2744
+ if (
2745
+ oldElement instanceof HTMLInputElement &&
2746
+ newElement instanceof HTMLInputElement &&
2747
+ newElement.type !== "file"
2748
+ ) {
2749
+ let newValue = newElement.value;
2750
+ let oldValue = oldElement.value;
2751
+
2752
+ // sync boolean attributes
2753
+ syncBooleanAttribute(oldElement, newElement, "checked", ctx);
2754
+ syncBooleanAttribute(oldElement, newElement, "disabled", ctx);
2755
+
2756
+ if (!newElement.hasAttribute("value")) {
2757
+ if (!ignoreAttribute("value", oldElement, "remove", ctx)) {
2758
+ oldElement.value = "";
2759
+ oldElement.removeAttribute("value");
2760
+ }
2761
+ } else if (oldValue !== newValue) {
2762
+ if (!ignoreAttribute("value", oldElement, "update", ctx)) {
2763
+ oldElement.setAttribute("value", newValue);
2764
+ oldElement.value = newValue;
2765
+ }
2572
2766
  }
2767
+ // TODO: QUESTION(1cg): this used to only check `newElement` unlike the other branches -- why?
2768
+ // did I break something?
2769
+ } else if (
2770
+ oldElement instanceof HTMLOptionElement &&
2771
+ newElement instanceof HTMLOptionElement
2772
+ ) {
2773
+ syncBooleanAttribute(oldElement, newElement, "selected", ctx);
2774
+ } else if (
2775
+ oldElement instanceof HTMLTextAreaElement &&
2776
+ newElement instanceof HTMLTextAreaElement
2777
+ ) {
2778
+ let newValue = newElement.value;
2779
+ let oldValue = oldElement.value;
2780
+ if (ignoreAttribute("value", oldElement, "update", ctx)) {
2781
+ return;
2782
+ }
2783
+ if (newValue !== oldValue) {
2784
+ oldElement.value = newValue;
2785
+ }
2786
+ if (
2787
+ oldElement.firstChild &&
2788
+ oldElement.firstChild.nodeValue !== newValue
2789
+ ) {
2790
+ oldElement.firstChild.nodeValue = newValue;
2791
+ }
2792
+ }
2793
+ }
2573
2794
 
2574
- //=============================================================================
2575
- // Scans forward from the insertionPoint in the old parent looking for a potential id match
2576
- // for the newChild. We stop if we find a potential id match for the new child OR
2577
- // if the number of potential id matches we are discarding is greater than the
2578
- // potential id matches for the new child
2579
- //=============================================================================
2580
- function findIdSetMatch(newContent, oldParent, newChild, insertionPoint, ctx) {
2581
-
2582
- // max id matches we are willing to discard in our search
2583
- let newChildPotentialIdCount = getIdIntersectionCount(ctx, newChild, oldParent);
2584
-
2585
- let potentialMatch = null;
2586
-
2587
- // only search forward if there is a possibility of an id match
2588
- if (newChildPotentialIdCount > 0) {
2589
- let potentialMatch = insertionPoint;
2590
- // if there is a possibility of an id match, scan forward
2591
- // keep track of the potential id match count we are discarding (the
2592
- // newChildPotentialIdCount must be greater than this to make it likely
2593
- // worth it)
2594
- let otherMatchCount = 0;
2595
- while (potentialMatch != null) {
2596
-
2597
- // If we have an id match, return the current potential match
2598
- if (isIdSetMatch(newChild, potentialMatch, ctx)) {
2599
- return potentialMatch;
2600
- }
2601
-
2602
- // computer the other potential matches of this new content
2603
- otherMatchCount += getIdIntersectionCount(ctx, potentialMatch, newContent);
2604
- if (otherMatchCount > newChildPotentialIdCount) {
2605
- // if we have more potential id matches in _other_ content, we
2606
- // do not have a good candidate for an id match, so return null
2607
- return null;
2608
- }
2609
-
2610
- // advanced to the next old content child
2611
- potentialMatch = potentialMatch.nextSibling;
2612
- }
2613
- }
2614
- return potentialMatch;
2795
+ /**
2796
+ * @param {Element} oldElement element to write the value to
2797
+ * @param {Element} newElement element to read the value from
2798
+ * @param {string} attributeName the attribute name
2799
+ * @param {MorphContext} ctx the merge context
2800
+ */
2801
+ function syncBooleanAttribute(oldElement, newElement, attributeName, ctx) {
2802
+ // @ts-ignore this function is only used on boolean attrs that are reflected as dom properties
2803
+ const newLiveValue = newElement[attributeName],
2804
+ // @ts-ignore ditto
2805
+ oldLiveValue = oldElement[attributeName];
2806
+ if (newLiveValue !== oldLiveValue) {
2807
+ const ignoreUpdate = ignoreAttribute(
2808
+ attributeName,
2809
+ oldElement,
2810
+ "update",
2811
+ ctx,
2812
+ );
2813
+ if (!ignoreUpdate) {
2814
+ // update attribute's associated DOM property
2815
+ // @ts-ignore this function is only used on boolean attrs that are reflected as dom properties
2816
+ oldElement[attributeName] = newElement[attributeName];
2615
2817
  }
2818
+ if (newLiveValue) {
2819
+ if (!ignoreUpdate) {
2820
+ // https://developer.mozilla.org/en-US/docs/Glossary/Boolean/HTML
2821
+ // this is the correct way to set a boolean attribute to "true"
2822
+ oldElement.setAttribute(attributeName, "");
2823
+ }
2824
+ } else {
2825
+ if (!ignoreAttribute(attributeName, oldElement, "remove", ctx)) {
2826
+ oldElement.removeAttribute(attributeName);
2827
+ }
2828
+ }
2829
+ }
2830
+ }
2616
2831
 
2617
- //=============================================================================
2618
- // Scans forward from the insertionPoint in the old parent looking for a potential soft match
2619
- // for the newChild. We stop if we find a potential soft match for the new child OR
2620
- // if we find a potential id match in the old parents children OR if we find two
2621
- // potential soft matches for the next two pieces of new content
2622
- //=============================================================================
2623
- function findSoftMatch(newContent, oldParent, newChild, insertionPoint, ctx) {
2624
-
2625
- let potentialSoftMatch = insertionPoint;
2626
- let nextSibling = newChild.nextSibling;
2627
- let siblingSoftMatchCount = 0;
2628
-
2629
- while (potentialSoftMatch != null) {
2630
-
2631
- if (getIdIntersectionCount(ctx, potentialSoftMatch, newContent) > 0) {
2632
- // the current potential soft match has a potential id set match with the remaining new
2633
- // content so bail out of looking
2634
- return null;
2635
- }
2636
-
2637
- // if we have a soft match with the current node, return it
2638
- if (isSoftMatch(newChild, potentialSoftMatch)) {
2639
- return potentialSoftMatch;
2640
- }
2641
-
2642
- if (isSoftMatch(nextSibling, potentialSoftMatch)) {
2643
- // the next new node has a soft match with this node, so
2644
- // increment the count of future soft matches
2645
- siblingSoftMatchCount++;
2646
- nextSibling = nextSibling.nextSibling;
2647
-
2648
- // If there are two future soft matches, bail to allow the siblings to soft match
2649
- // so that we don't consume future soft matches for the sake of the current node
2650
- if (siblingSoftMatchCount >= 2) {
2651
- return null;
2652
- }
2653
- }
2654
-
2655
- // advanced to the next old content child
2656
- potentialSoftMatch = potentialSoftMatch.nextSibling;
2657
- }
2832
+ /**
2833
+ * @param {string} attr the attribute to be mutated
2834
+ * @param {Element} element the element that is going to be updated
2835
+ * @param {"update" | "remove"} updateType
2836
+ * @param {MorphContext} ctx the merge context
2837
+ * @returns {boolean} true if the attribute should be ignored, false otherwise
2838
+ */
2839
+ function ignoreAttribute(attr, element, updateType, ctx) {
2840
+ if (
2841
+ attr === "value" &&
2842
+ ctx.ignoreActiveValue &&
2843
+ element === document.activeElement
2844
+ ) {
2845
+ return true;
2846
+ }
2847
+ return (
2848
+ ctx.callbacks.beforeAttributeUpdated(attr, element, updateType) ===
2849
+ false
2850
+ );
2851
+ }
2658
2852
 
2659
- return potentialSoftMatch;
2660
- }
2853
+ /**
2854
+ * @param {Node} possibleActiveElement
2855
+ * @param {MorphContext} ctx
2856
+ * @returns {boolean}
2857
+ */
2858
+ function ignoreValueOfActiveElement(possibleActiveElement, ctx) {
2859
+ return (
2860
+ !!ctx.ignoreActiveValue &&
2861
+ possibleActiveElement === document.activeElement &&
2862
+ possibleActiveElement !== document.body
2863
+ );
2864
+ }
2661
2865
 
2662
- function parseContent(newContent) {
2663
- let parser = new DOMParser();
2664
-
2665
- // remove svgs to avoid false-positive matches on head, etc.
2666
- let contentWithSvgsRemoved = newContent.replace(/<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim, '');
2667
-
2668
- // if the newContent contains a html, head or body tag, we can simply parse it w/o wrapping
2669
- if (contentWithSvgsRemoved.match(/<\/html>/) || contentWithSvgsRemoved.match(/<\/head>/) || contentWithSvgsRemoved.match(/<\/body>/)) {
2670
- let content = parser.parseFromString(newContent, "text/html");
2671
- // if it is a full HTML document, return the document itself as the parent container
2672
- if (contentWithSvgsRemoved.match(/<\/html>/)) {
2673
- content.generatedByIdiomorph = true;
2674
- return content;
2675
- } else {
2676
- // otherwise return the html element as the parent container
2677
- let htmlElement = content.firstChild;
2678
- if (htmlElement) {
2679
- htmlElement.generatedByIdiomorph = true;
2680
- return htmlElement;
2681
- } else {
2682
- return null;
2683
- }
2684
- }
2685
- } else {
2686
- // if it is partial HTML, wrap it in a template tag to provide a parent element and also to help
2687
- // deal with touchy tags like tr, tbody, etc.
2688
- let responseDoc = parser.parseFromString("<body><template>" + newContent + "</template></body>", "text/html");
2689
- let content = responseDoc.body.querySelector('template').content;
2690
- content.generatedByIdiomorph = true;
2691
- return content
2692
- }
2693
- }
2866
+ return morphNode;
2867
+ })();
2694
2868
 
2695
- function normalizeContent(newContent) {
2696
- if (newContent == null) {
2697
- // noinspection UnnecessaryLocalVariableJS
2698
- const dummyParent = document.createElement('div');
2699
- return dummyParent;
2700
- } else if (newContent.generatedByIdiomorph) {
2701
- // the template tag created by idiomorph parsing can serve as a dummy parent
2702
- return newContent;
2703
- } else if (newContent instanceof Node) {
2704
- // a single node is added as a child to a dummy parent
2705
- const dummyParent = document.createElement('div');
2706
- dummyParent.append(newContent);
2707
- return dummyParent;
2708
- } else {
2709
- // all nodes in the array or HTMLElement collection are consolidated under
2710
- // a single dummy parent element
2711
- const dummyParent = document.createElement('div');
2712
- for (const elt of [...newContent]) {
2713
- dummyParent.append(elt);
2714
- }
2715
- return dummyParent;
2716
- }
2717
- }
2869
+ //=============================================================================
2870
+ // Head Management Functions
2871
+ //=============================================================================
2872
+ /**
2873
+ * @param {MorphContext} ctx
2874
+ * @param {Element} oldNode
2875
+ * @param {Element} newNode
2876
+ * @param {function} callback
2877
+ * @returns {Node[] | Promise<Node[]>}
2878
+ */
2879
+ function withHeadBlocking(ctx, oldNode, newNode, callback) {
2880
+ if (ctx.head.block) {
2881
+ const oldHead = oldNode.querySelector("head");
2882
+ const newHead = newNode.querySelector("head");
2883
+ if (oldHead && newHead) {
2884
+ const promises = handleHeadElement(oldHead, newHead, ctx);
2885
+ // when head promises resolve, proceed ignoring the head tag
2886
+ return Promise.all(promises).then(() => {
2887
+ const newCtx = Object.assign(ctx, {
2888
+ head: {
2889
+ block: false,
2890
+ ignore: true,
2891
+ },
2892
+ });
2893
+ return callback(newCtx);
2894
+ });
2895
+ }
2896
+ }
2897
+ // just proceed if we not head blocking
2898
+ return callback(ctx);
2899
+ }
2718
2900
 
2719
- function insertSiblings(previousSibling, morphedNode, nextSibling) {
2720
- let stack = [];
2721
- let added = [];
2722
- while (previousSibling != null) {
2723
- stack.push(previousSibling);
2724
- previousSibling = previousSibling.previousSibling;
2725
- }
2726
- while (stack.length > 0) {
2727
- let node = stack.pop();
2728
- added.push(node); // push added preceding siblings on in order and insert
2729
- morphedNode.parentElement.insertBefore(node, morphedNode);
2730
- }
2731
- added.push(morphedNode);
2732
- while (nextSibling != null) {
2733
- stack.push(nextSibling);
2734
- added.push(nextSibling); // here we are going in order, so push on as we scan, rather than add
2735
- nextSibling = nextSibling.nextSibling;
2736
- }
2737
- while (stack.length > 0) {
2738
- morphedNode.parentElement.insertBefore(stack.pop(), morphedNode.nextSibling);
2739
- }
2740
- return added;
2901
+ /**
2902
+ * The HEAD tag can be handled specially, either w/ a 'merge' or 'append' style
2903
+ *
2904
+ * @param {Element} oldHead
2905
+ * @param {Element} newHead
2906
+ * @param {MorphContext} ctx
2907
+ * @returns {Promise<void>[]}
2908
+ */
2909
+ function handleHeadElement(oldHead, newHead, ctx) {
2910
+ let added = [];
2911
+ let removed = [];
2912
+ let preserved = [];
2913
+ let nodesToAppend = [];
2914
+
2915
+ // put all new head elements into a Map, by their outerHTML
2916
+ let srcToNewHeadNodes = new Map();
2917
+ for (const newHeadChild of newHead.children) {
2918
+ srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild);
2919
+ }
2920
+
2921
+ // for each elt in the current head
2922
+ for (const currentHeadElt of oldHead.children) {
2923
+ // If the current head element is in the map
2924
+ let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML);
2925
+ let isReAppended = ctx.head.shouldReAppend(currentHeadElt);
2926
+ let isPreserved = ctx.head.shouldPreserve(currentHeadElt);
2927
+ if (inNewContent || isPreserved) {
2928
+ if (isReAppended) {
2929
+ // remove the current version and let the new version replace it and re-execute
2930
+ removed.push(currentHeadElt);
2931
+ } else {
2932
+ // this element already exists and should not be re-appended, so remove it from
2933
+ // the new content map, preserving it in the DOM
2934
+ srcToNewHeadNodes.delete(currentHeadElt.outerHTML);
2935
+ preserved.push(currentHeadElt);
2741
2936
  }
2742
-
2743
- function findBestNodeMatch(newContent, oldNode, ctx) {
2744
- let currentElement;
2745
- currentElement = newContent.firstChild;
2746
- let bestElement = currentElement;
2747
- let score = 0;
2748
- while (currentElement) {
2749
- let newScore = scoreElement(currentElement, oldNode, ctx);
2750
- if (newScore > score) {
2751
- bestElement = currentElement;
2752
- score = newScore;
2753
- }
2754
- currentElement = currentElement.nextSibling;
2755
- }
2756
- return bestElement;
2937
+ } else {
2938
+ if (ctx.head.style === "append") {
2939
+ // we are appending and this existing element is not new content
2940
+ // so if and only if it is marked for re-append do we do anything
2941
+ if (isReAppended) {
2942
+ removed.push(currentHeadElt);
2943
+ nodesToAppend.push(currentHeadElt);
2944
+ }
2945
+ } else {
2946
+ // if this is a merge, we remove this content since it is not in the new head
2947
+ if (ctx.head.shouldRemove(currentHeadElt) !== false) {
2948
+ removed.push(currentHeadElt);
2949
+ }
2757
2950
  }
2951
+ }
2952
+ }
2758
2953
 
2759
- function scoreElement(node1, node2, ctx) {
2760
- if (isSoftMatch(node1, node2)) {
2761
- return .5 + getIdIntersectionCount(ctx, node1, node2);
2762
- }
2763
- return 0;
2954
+ // Push the remaining new head elements in the Map into the
2955
+ // nodes to append to the head tag
2956
+ nodesToAppend.push(...srcToNewHeadNodes.values());
2957
+
2958
+ let promises = [];
2959
+ for (const newNode of nodesToAppend) {
2960
+ // TODO: This could theoretically be null, based on type
2961
+ let newElt = /** @type {ChildNode} */ (
2962
+ document.createRange().createContextualFragment(newNode.outerHTML)
2963
+ .firstChild
2964
+ );
2965
+ if (ctx.callbacks.beforeNodeAdded(newElt) !== false) {
2966
+ if (
2967
+ ("href" in newElt && newElt.href) ||
2968
+ ("src" in newElt && newElt.src)
2969
+ ) {
2970
+ /** @type {(result?: any) => void} */ let resolve;
2971
+ let promise = new Promise(function (_resolve) {
2972
+ resolve = _resolve;
2973
+ });
2974
+ newElt.addEventListener("load", function () {
2975
+ resolve();
2976
+ });
2977
+ promises.push(promise);
2764
2978
  }
2979
+ oldHead.appendChild(newElt);
2980
+ ctx.callbacks.afterNodeAdded(newElt);
2981
+ added.push(newElt);
2982
+ }
2983
+ }
2984
+
2985
+ // remove all removed elements, after we have appended the new elements to avoid
2986
+ // additional network requests for things like style sheets
2987
+ for (const removedElement of removed) {
2988
+ if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) {
2989
+ oldHead.removeChild(removedElement);
2990
+ ctx.callbacks.afterNodeRemoved(removedElement);
2991
+ }
2992
+ }
2765
2993
 
2766
- function removeNode(tempNode, ctx) {
2767
- removeIdsFromConsideration(ctx, tempNode);
2768
- if (ctx.callbacks.beforeNodeRemoved(tempNode) === false) return;
2994
+ ctx.head.afterHeadMorphed(oldHead, {
2995
+ added: added,
2996
+ kept: preserved,
2997
+ removed: removed,
2998
+ });
2999
+ return promises;
3000
+ }
3001
+
3002
+ //=============================================================================
3003
+ // Create Morph Context Functions
3004
+ //=============================================================================
3005
+ const createMorphContext = (function () {
3006
+ /**
3007
+ *
3008
+ * @param {Element} oldNode
3009
+ * @param {Element} newContent
3010
+ * @param {Config} config
3011
+ * @returns {MorphContext}
3012
+ */
3013
+ function createMorphContext(oldNode, newContent, config) {
3014
+ const { persistentIds, idMap } = createIdMaps(oldNode, newContent);
3015
+
3016
+ const mergedConfig = mergeDefaults(config);
3017
+ const morphStyle = mergedConfig.morphStyle || "outerHTML";
3018
+ if (!["innerHTML", "outerHTML"].includes(morphStyle)) {
3019
+ throw `Do not understand how to morph style ${morphStyle}`;
3020
+ }
2769
3021
 
2770
- tempNode.remove();
2771
- ctx.callbacks.afterNodeRemoved(tempNode);
2772
- }
3022
+ return {
3023
+ target: oldNode,
3024
+ newContent: newContent,
3025
+ config: mergedConfig,
3026
+ morphStyle: morphStyle,
3027
+ ignoreActive: mergedConfig.ignoreActive,
3028
+ ignoreActiveValue: mergedConfig.ignoreActiveValue,
3029
+ restoreFocus: mergedConfig.restoreFocus,
3030
+ idMap: idMap,
3031
+ persistentIds: persistentIds,
3032
+ pantry: createPantry(),
3033
+ callbacks: mergedConfig.callbacks,
3034
+ head: mergedConfig.head,
3035
+ };
3036
+ }
2773
3037
 
2774
- //=============================================================================
2775
- // ID Set Functions
2776
- //=============================================================================
3038
+ /**
3039
+ * Deep merges the config object and the Idiomorph.defaults object to
3040
+ * produce a final configuration object
3041
+ * @param {Config} config
3042
+ * @returns {ConfigInternal}
3043
+ */
3044
+ function mergeDefaults(config) {
3045
+ let finalConfig = Object.assign({}, defaults);
2777
3046
 
2778
- function isIdInConsideration(ctx, id) {
2779
- return !ctx.deadIds.has(id);
2780
- }
3047
+ // copy top level stuff into final config
3048
+ Object.assign(finalConfig, config);
2781
3049
 
2782
- function idIsWithinNode(ctx, id, targetNode) {
2783
- let idSet = ctx.idMap.get(targetNode) || EMPTY_SET;
2784
- return idSet.has(id);
2785
- }
3050
+ // copy callbacks into final config (do this to deep merge the callbacks)
3051
+ finalConfig.callbacks = Object.assign(
3052
+ {},
3053
+ defaults.callbacks,
3054
+ config.callbacks,
3055
+ );
2786
3056
 
2787
- function removeIdsFromConsideration(ctx, node) {
2788
- let idSet = ctx.idMap.get(node) || EMPTY_SET;
2789
- for (const id of idSet) {
2790
- ctx.deadIds.add(id);
3057
+ // copy head config into final config (do this to deep merge the head)
3058
+ finalConfig.head = Object.assign({}, defaults.head, config.head);
3059
+
3060
+ return finalConfig;
3061
+ }
3062
+
3063
+ /**
3064
+ * @returns {HTMLDivElement}
3065
+ */
3066
+ function createPantry() {
3067
+ const pantry = document.createElement("div");
3068
+ pantry.hidden = true;
3069
+ document.body.insertAdjacentElement("afterend", pantry);
3070
+ return pantry;
3071
+ }
3072
+
3073
+ /**
3074
+ * Returns all elements with an ID contained within the root element and its descendants
3075
+ *
3076
+ * @param {Element} root
3077
+ * @returns {Element[]}
3078
+ */
3079
+ function findIdElements(root) {
3080
+ let elements = Array.from(root.querySelectorAll("[id]"));
3081
+ if (root.id) {
3082
+ elements.push(root);
3083
+ }
3084
+ return elements;
3085
+ }
3086
+
3087
+ /**
3088
+ * A bottom-up algorithm that populates a map of Element -> IdSet.
3089
+ * The idSet for a given element is the set of all IDs contained within its subtree.
3090
+ * As an optimzation, we filter these IDs through the given list of persistent IDs,
3091
+ * because we don't need to bother considering IDed elements that won't be in the new content.
3092
+ *
3093
+ * @param {Map<Node, Set<string>>} idMap
3094
+ * @param {Set<string>} persistentIds
3095
+ * @param {Element} root
3096
+ * @param {Element[]} elements
3097
+ */
3098
+ function populateIdMapWithTree(idMap, persistentIds, root, elements) {
3099
+ for (const elt of elements) {
3100
+ if (persistentIds.has(elt.id)) {
3101
+ /** @type {Element|null} */
3102
+ let current = elt;
3103
+ // walk up the parent hierarchy of that element, adding the id
3104
+ // of element to the parent's id set
3105
+ while (current) {
3106
+ let idSet = idMap.get(current);
3107
+ // if the id set doesn't exist, create it and insert it in the map
3108
+ if (idSet == null) {
3109
+ idSet = new Set();
3110
+ idMap.set(current, idSet);
2791
3111
  }
3112
+ idSet.add(elt.id);
3113
+
3114
+ if (current === root) break;
3115
+ current = current.parentElement;
3116
+ }
2792
3117
  }
3118
+ }
3119
+ }
2793
3120
 
2794
- function getIdIntersectionCount(ctx, node1, node2) {
2795
- let sourceSet = ctx.idMap.get(node1) || EMPTY_SET;
2796
- let matchCount = 0;
2797
- for (const id of sourceSet) {
2798
- // a potential match is an id in the source and potentialIdsSet, but
2799
- // that has not already been merged into the DOM
2800
- if (isIdInConsideration(ctx, id) && idIsWithinNode(ctx, id, node2)) {
2801
- ++matchCount;
2802
- }
2803
- }
2804
- return matchCount;
3121
+ /**
3122
+ * This function computes a map of nodes to all ids contained within that node (inclusive of the
3123
+ * node). This map can be used to ask if two nodes have intersecting sets of ids, which allows
3124
+ * for a looser definition of "matching" than tradition id matching, and allows child nodes
3125
+ * to contribute to a parent nodes matching.
3126
+ *
3127
+ * @param {Element} oldContent the old content that will be morphed
3128
+ * @param {Element} newContent the new content to morph to
3129
+ * @returns {IdSets}
3130
+ */
3131
+ function createIdMaps(oldContent, newContent) {
3132
+ const oldIdElements = findIdElements(oldContent);
3133
+ const newIdElements = findIdElements(newContent);
3134
+
3135
+ const persistentIds = createPersistentIds(oldIdElements, newIdElements);
3136
+
3137
+ /** @type {Map<Node, Set<string>>} */
3138
+ let idMap = new Map();
3139
+ populateIdMapWithTree(idMap, persistentIds, oldContent, oldIdElements);
3140
+
3141
+ /** @ts-ignore - if newContent is a duck-typed parent, pass its single child node as the root to halt upwards iteration */
3142
+ const newRoot = newContent.__idiomorphRoot || newContent;
3143
+ populateIdMapWithTree(idMap, persistentIds, newRoot, newIdElements);
3144
+
3145
+ return { persistentIds, idMap };
3146
+ }
3147
+
3148
+ /**
3149
+ * This function computes the set of ids that persist between the two contents excluding duplicates
3150
+ *
3151
+ * @param {Element[]} oldIdElements
3152
+ * @param {Element[]} newIdElements
3153
+ * @returns {Set<string>}
3154
+ */
3155
+ function createPersistentIds(oldIdElements, newIdElements) {
3156
+ let duplicateIds = new Set();
3157
+
3158
+ /** @type {Map<string, string>} */
3159
+ let oldIdTagNameMap = new Map();
3160
+ for (const { id, tagName } of oldIdElements) {
3161
+ if (oldIdTagNameMap.has(id)) {
3162
+ duplicateIds.add(id);
3163
+ } else {
3164
+ oldIdTagNameMap.set(id, tagName);
2805
3165
  }
3166
+ }
2806
3167
 
2807
- /**
2808
- * A bottom up algorithm that finds all elements with ids inside of the node
2809
- * argument and populates id sets for those nodes and all their parents, generating
2810
- * a set of ids contained within all nodes for the entire hierarchy in the DOM
2811
- *
2812
- * @param node {Element}
2813
- * @param {Map<Node, Set<String>>} idMap
2814
- */
2815
- function populateIdMapForNode(node, idMap) {
2816
- let nodeParent = node.parentElement;
2817
- // find all elements with an id property
2818
- let idElements = node.querySelectorAll('[id]');
2819
- for (const elt of idElements) {
2820
- let current = elt;
2821
- // walk up the parent hierarchy of that element, adding the id
2822
- // of element to the parent's id set
2823
- while (current !== nodeParent && current != null) {
2824
- let idSet = idMap.get(current);
2825
- // if the id set doesn't exist, create it and insert it in the map
2826
- if (idSet == null) {
2827
- idSet = new Set();
2828
- idMap.set(current, idSet);
2829
- }
2830
- idSet.add(elt.id);
2831
- current = current.parentElement;
2832
- }
2833
- }
3168
+ let persistentIds = new Set();
3169
+ for (const { id, tagName } of newIdElements) {
3170
+ if (persistentIds.has(id)) {
3171
+ duplicateIds.add(id);
3172
+ } else if (oldIdTagNameMap.get(id) === tagName) {
3173
+ persistentIds.add(id);
2834
3174
  }
3175
+ // skip if tag types mismatch because its not possible to morph one tag into another
3176
+ }
3177
+
3178
+ for (const id of duplicateIds) {
3179
+ persistentIds.delete(id);
3180
+ }
3181
+ return persistentIds;
3182
+ }
3183
+
3184
+ return createMorphContext;
3185
+ })();
3186
+
3187
+ //=============================================================================
3188
+ // HTML Normalization Functions
3189
+ //=============================================================================
3190
+ const { normalizeElement, normalizeParent } = (function () {
3191
+ /** @type {WeakSet<Node>} */
3192
+ const generatedByIdiomorph = new WeakSet();
3193
+
3194
+ /**
3195
+ *
3196
+ * @param {Element | Document} content
3197
+ * @returns {Element}
3198
+ */
3199
+ function normalizeElement(content) {
3200
+ if (content instanceof Document) {
3201
+ return content.documentElement;
3202
+ } else {
3203
+ return content;
3204
+ }
3205
+ }
2835
3206
 
2836
- /**
2837
- * This function computes a map of nodes to all ids contained within that node (inclusive of the
2838
- * node). This map can be used to ask if two nodes have intersecting sets of ids, which allows
2839
- * for a looser definition of "matching" than tradition id matching, and allows child nodes
2840
- * to contribute to a parent nodes matching.
2841
- *
2842
- * @param {Element} oldContent the old content that will be morphed
2843
- * @param {Element} newContent the new content to morph to
2844
- * @returns {Map<Node, Set<String>>} a map of nodes to id sets for the
2845
- */
2846
- function createIdMap(oldContent, newContent) {
2847
- let idMap = new Map();
2848
- populateIdMapForNode(oldContent, idMap);
2849
- populateIdMapForNode(newContent, idMap);
2850
- return idMap;
3207
+ /**
3208
+ *
3209
+ * @param {null | string | Node | HTMLCollection | Node[] | Document & {generatedByIdiomorph:boolean}} newContent
3210
+ * @returns {Element}
3211
+ */
3212
+ function normalizeParent(newContent) {
3213
+ if (newContent == null) {
3214
+ return document.createElement("div"); // dummy parent element
3215
+ } else if (typeof newContent === "string") {
3216
+ return normalizeParent(parseContent(newContent));
3217
+ } else if (
3218
+ generatedByIdiomorph.has(/** @type {Element} */ (newContent))
3219
+ ) {
3220
+ // the template tag created by idiomorph parsing can serve as a dummy parent
3221
+ return /** @type {Element} */ (newContent);
3222
+ } else if (newContent instanceof Node) {
3223
+ if (newContent.parentNode) {
3224
+ // we can't use the parent directly because newContent may have siblings
3225
+ // that we don't want in the morph, and reparenting might be expensive (TODO is it?),
3226
+ // so we create a duck-typed parent node instead.
3227
+ return createDuckTypedParent(newContent);
3228
+ } else {
3229
+ // a single node is added as a child to a dummy parent
3230
+ const dummyParent = document.createElement("div");
3231
+ dummyParent.append(newContent);
3232
+ return dummyParent;
3233
+ }
3234
+ } else {
3235
+ // all nodes in the array or HTMLElement collection are consolidated under
3236
+ // a single dummy parent element
3237
+ const dummyParent = document.createElement("div");
3238
+ for (const elt of [...newContent]) {
3239
+ dummyParent.append(elt);
2851
3240
  }
3241
+ return dummyParent;
3242
+ }
3243
+ }
3244
+
3245
+ /**
3246
+ * Creates a fake duck-typed parent element to wrap a single node, without actually reparenting it.
3247
+ * "If it walks like a duck, and quacks like a duck, then it must be a duck!" -- James Whitcomb Riley (1849–1916)
3248
+ *
3249
+ * @param {Node} newContent
3250
+ * @returns {Element}
3251
+ */
3252
+ function createDuckTypedParent(newContent) {
3253
+ return /** @type {Element} */ (
3254
+ /** @type {unknown} */ ({
3255
+ childNodes: [newContent],
3256
+ /** @ts-ignore - cover your eyes for a minute, tsc */
3257
+ querySelectorAll: (s) => {
3258
+ /** @ts-ignore */
3259
+ const elements = newContent.querySelectorAll(s);
3260
+ /** @ts-ignore */
3261
+ return newContent.matches(s) ? [newContent, ...elements] : elements;
3262
+ },
3263
+ /** @ts-ignore */
3264
+ insertBefore: (n, r) => newContent.parentNode.insertBefore(n, r),
3265
+ /** @ts-ignore */
3266
+ moveBefore: (n, r) => newContent.parentNode.moveBefore(n, r),
3267
+ // for later use with populateIdMapWithTree to halt upwards iteration
3268
+ get __idiomorphRoot() {
3269
+ return newContent;
3270
+ },
3271
+ })
3272
+ );
3273
+ }
3274
+
3275
+ /**
3276
+ *
3277
+ * @param {string} newContent
3278
+ * @returns {Node | null | DocumentFragment}
3279
+ */
3280
+ function parseContent(newContent) {
3281
+ let parser = new DOMParser();
2852
3282
 
2853
- //=============================================================================
2854
- // This is what ends up becoming the Idiomorph global object
2855
- //=============================================================================
2856
- return {
2857
- morph,
2858
- defaults
3283
+ // remove svgs to avoid false-positive matches on head, etc.
3284
+ let contentWithSvgsRemoved = newContent.replace(
3285
+ /<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim,
3286
+ "",
3287
+ );
3288
+
3289
+ // if the newContent contains a html, head or body tag, we can simply parse it w/o wrapping
3290
+ if (
3291
+ contentWithSvgsRemoved.match(/<\/html>/) ||
3292
+ contentWithSvgsRemoved.match(/<\/head>/) ||
3293
+ contentWithSvgsRemoved.match(/<\/body>/)
3294
+ ) {
3295
+ let content = parser.parseFromString(newContent, "text/html");
3296
+ // if it is a full HTML document, return the document itself as the parent container
3297
+ if (contentWithSvgsRemoved.match(/<\/html>/)) {
3298
+ generatedByIdiomorph.add(content);
3299
+ return content;
3300
+ } else {
3301
+ // otherwise return the html element as the parent container
3302
+ let htmlElement = content.firstChild;
3303
+ if (htmlElement) {
3304
+ generatedByIdiomorph.add(htmlElement);
3305
+ }
3306
+ return htmlElement;
2859
3307
  }
2860
- })();
3308
+ } else {
3309
+ // if it is partial HTML, wrap it in a template tag to provide a parent element and also to help
3310
+ // deal with touchy tags like tr, tbody, etc.
3311
+ let responseDoc = parser.parseFromString(
3312
+ "<body><template>" + newContent + "</template></body>",
3313
+ "text/html",
3314
+ );
3315
+ let content = /** @type {HTMLTemplateElement} */ (
3316
+ responseDoc.body.querySelector("template")
3317
+ ).content;
3318
+ generatedByIdiomorph.add(content);
3319
+ return content;
3320
+ }
3321
+ }
3322
+
3323
+ return { normalizeElement, normalizeParent };
3324
+ })();
3325
+
3326
+ //=============================================================================
3327
+ // This is what ends up becoming the Idiomorph global object
3328
+ //=============================================================================
3329
+ return {
3330
+ morph,
3331
+ defaults,
3332
+ };
3333
+ })();
2861
3334
 
3335
+ /**
3336
+ * Morph the state of the currentElement based on the attributes and contents of
3337
+ * the newElement. Morphing may dispatch turbo:before-morph-element,
3338
+ * turbo:before-morph-attribute, and turbo:morph-element events.
3339
+ *
3340
+ * @param currentElement Element destination of morphing changes
3341
+ * @param newElement Element source of morphing changes
3342
+ */
2862
3343
  function morphElements(currentElement, newElement, { callbacks, ...options } = {}) {
2863
3344
  Idiomorph.morph(currentElement, newElement, {
2864
3345
  ...options,
@@ -2866,12 +3347,37 @@ function morphElements(currentElement, newElement, { callbacks, ...options } = {
2866
3347
  });
2867
3348
  }
2868
3349
 
2869
- function morphChildren(currentElement, newElement) {
2870
- morphElements(currentElement, newElement.children, {
3350
+ /**
3351
+ * Morph the child elements of the currentElement based on the child elements of
3352
+ * the newElement. Morphing children may dispatch turbo:before-morph-element,
3353
+ * turbo:before-morph-attribute, and turbo:morph-element events.
3354
+ *
3355
+ * @param currentElement Element destination of morphing children changes
3356
+ * @param newElement Element source of morphing children changes
3357
+ */
3358
+ function morphChildren(currentElement, newElement, options = {}) {
3359
+ morphElements(currentElement, newElement.childNodes, {
3360
+ ...options,
2871
3361
  morphStyle: "innerHTML"
2872
3362
  });
2873
3363
  }
2874
3364
 
3365
+ function shouldRefreshFrameWithMorphing(currentFrame, newFrame) {
3366
+ return currentFrame instanceof FrameElement &&
3367
+ // newFrame cannot yet be an instance of FrameElement because custom
3368
+ // elements don't get initialized until they're attached to the DOM, so
3369
+ // test its Element#nodeName instead
3370
+ newFrame instanceof Element && newFrame.nodeName === "TURBO-FRAME" &&
3371
+ currentFrame.shouldReloadWithMorph &&
3372
+ currentFrame.id === newFrame.id &&
3373
+ (!newFrame.getAttribute("src") || urlsAreEqual(currentFrame.src, newFrame.getAttribute("src"))) &&
3374
+ !currentFrame.closest("[data-turbo-permanent]")
3375
+ }
3376
+
3377
+ function closestFrameReloadableWithMorphing(node) {
3378
+ return node.parentElement.closest("turbo-frame[src][refresh=morph]")
3379
+ }
3380
+
2875
3381
  class DefaultIdiomorphCallbacks {
2876
3382
  #beforeNodeMorphed
2877
3383
 
@@ -2930,7 +3436,20 @@ class MorphingFrameRenderer extends FrameRenderer {
2930
3436
  detail: { currentElement, newElement }
2931
3437
  });
2932
3438
 
2933
- morphChildren(currentElement, newElement);
3439
+ morphChildren(currentElement, newElement, {
3440
+ callbacks: {
3441
+ beforeNodeMorphed: (node, newNode) => {
3442
+ if (
3443
+ shouldRefreshFrameWithMorphing(node, newNode) &&
3444
+ closestFrameReloadableWithMorphing(node) === currentElement
3445
+ ) {
3446
+ node.reload();
3447
+ return false
3448
+ }
3449
+ return true
3450
+ }
3451
+ }
3452
+ });
2934
3453
  }
2935
3454
 
2936
3455
  async preservingPermanentElements(callback) {
@@ -3239,7 +3758,8 @@ class PageSnapshot extends Snapshot {
3239
3758
  }
3240
3759
 
3241
3760
  get prefersViewTransitions() {
3242
- return this.headSnapshot.getMetaValue("view-transition") === "same-origin"
3761
+ const viewTransitionEnabled = this.getSetting("view-transition") === "true" || this.headSnapshot.getMetaValue("view-transition") === "same-origin";
3762
+ return viewTransitionEnabled && !window.matchMedia("(prefers-reduced-motion: reduce)").matches
3243
3763
  }
3244
3764
 
3245
3765
  get shouldMorphPage() {
@@ -3660,16 +4180,6 @@ class Visit {
3660
4180
 
3661
4181
  // Private
3662
4182
 
3663
- getHistoryMethodForAction(action) {
3664
- switch (action) {
3665
- case "replace":
3666
- return history.replaceState
3667
- case "advance":
3668
- case "restore":
3669
- return history.pushState
3670
- }
3671
- }
3672
-
3673
4183
  hasPreloadedResponse() {
3674
4184
  return typeof this.response == "object"
3675
4185
  }
@@ -3737,6 +4247,8 @@ class BrowserAdapter {
3737
4247
 
3738
4248
  visitStarted(visit) {
3739
4249
  this.location = visit.location;
4250
+ this.redirectedToLocation = null;
4251
+
3740
4252
  visit.loadCachedSnapshot();
3741
4253
  visit.issueRequest();
3742
4254
  visit.goToSamePageAnchor();
@@ -3753,6 +4265,10 @@ class BrowserAdapter {
3753
4265
 
3754
4266
  visitRequestCompleted(visit) {
3755
4267
  visit.loadResponse();
4268
+
4269
+ if (visit.response.redirected) {
4270
+ this.redirectedToLocation = visit.redirectedToLocation;
4271
+ }
3756
4272
  }
3757
4273
 
3758
4274
  visitRequestFailedWithStatusCode(visit, statusCode) {
@@ -3789,6 +4305,12 @@ class BrowserAdapter {
3789
4305
 
3790
4306
  visitRendered(_visit) {}
3791
4307
 
4308
+ // Link prefetching
4309
+
4310
+ linkPrefetchingIsEnabledForLocation(location) {
4311
+ return true
4312
+ }
4313
+
3792
4314
  // Form Submission Delegate
3793
4315
 
3794
4316
  formSubmissionStarted(_formSubmission) {
@@ -3836,7 +4358,7 @@ class BrowserAdapter {
3836
4358
  reload(reason) {
3837
4359
  dispatch("turbo:reload", { detail: reason });
3838
4360
 
3839
- window.location.href = this.location?.toString() || window.location.href;
4361
+ window.location.href = (this.redirectedToLocation || this.location)?.toString() || window.location.href;
3840
4362
  }
3841
4363
 
3842
4364
  get navigator() {
@@ -4149,6 +4671,8 @@ class LinkPrefetchObserver {
4149
4671
  target
4150
4672
  );
4151
4673
 
4674
+ fetchRequest.fetchOptions.priority = "low";
4675
+
4152
4676
  prefetchCache.setLater(location.toString(), fetchRequest, this.#cacheTtl);
4153
4677
  }
4154
4678
  }
@@ -4373,6 +4897,17 @@ class Navigator {
4373
4897
  }
4374
4898
  }
4375
4899
 
4900
+ // Link prefetching
4901
+
4902
+ linkPrefetchingIsEnabledForLocation(location) {
4903
+ // Not all adapters implement linkPrefetchingIsEnabledForLocation
4904
+ if (typeof this.adapter.linkPrefetchingIsEnabledForLocation === "function") {
4905
+ return this.adapter.linkPrefetchingIsEnabledForLocation(location)
4906
+ }
4907
+
4908
+ return true
4909
+ }
4910
+
4376
4911
  // Visit delegate
4377
4912
 
4378
4913
  visitStarted(visit) {
@@ -4947,14 +5482,19 @@ class MorphingPageRenderer extends PageRenderer {
4947
5482
  static renderElement(currentElement, newElement) {
4948
5483
  morphElements(currentElement, newElement, {
4949
5484
  callbacks: {
4950
- beforeNodeMorphed: element => !canRefreshFrame(element)
5485
+ beforeNodeMorphed: (node, newNode) => {
5486
+ if (
5487
+ shouldRefreshFrameWithMorphing(node, newNode) &&
5488
+ !closestFrameReloadableWithMorphing(node)
5489
+ ) {
5490
+ node.reload();
5491
+ return false
5492
+ }
5493
+ return true
5494
+ }
4951
5495
  }
4952
5496
  });
4953
5497
 
4954
- for (const frame of currentElement.querySelectorAll("turbo-frame")) {
4955
- if (canRefreshFrame(frame)) frame.reload();
4956
- }
4957
-
4958
5498
  dispatch("turbo:morph", { detail: { currentElement, newElement } });
4959
5499
  }
4960
5500
 
@@ -4971,13 +5511,6 @@ class MorphingPageRenderer extends PageRenderer {
4971
5511
  }
4972
5512
  }
4973
5513
 
4974
- function canRefreshFrame(frame) {
4975
- return frame instanceof FrameElement &&
4976
- frame.src &&
4977
- frame.refresh === "morph" &&
4978
- !frame.closest("[data-turbo-permanent]")
4979
- }
4980
-
4981
5514
  class SnapshotCache {
4982
5515
  keys = []
4983
5516
  snapshots = {}
@@ -5279,7 +5812,8 @@ class Session {
5279
5812
 
5280
5813
  refresh(url, requestId) {
5281
5814
  const isRecentRequest = requestId && this.recentRequests.has(requestId);
5282
- if (!isRecentRequest && !this.navigator.currentVisit) {
5815
+ const isCurrentUrl = url === document.baseURI;
5816
+ if (!isRecentRequest && !this.navigator.currentVisit && isCurrentUrl) {
5283
5817
  this.visit(url, { action: "replace", shouldCacheSnapshot: false });
5284
5818
  }
5285
5819
  }
@@ -5403,7 +5937,8 @@ class Session {
5403
5937
  canPrefetchRequestToLocation(link, location) {
5404
5938
  return (
5405
5939
  this.elementIsNavigatable(link) &&
5406
- locationIsVisitable(location, this.snapshot.rootLocation)
5940
+ locationIsVisitable(location, this.snapshot.rootLocation) &&
5941
+ this.navigator.linkPrefetchingIsEnabledForLocation(location)
5407
5942
  )
5408
5943
  }
5409
5944
 
@@ -5799,6 +6334,32 @@ function setFormMode(mode) {
5799
6334
  config.forms.mode = mode;
5800
6335
  }
5801
6336
 
6337
+ /**
6338
+ * Morph the state of the currentBody based on the attributes and contents of
6339
+ * the newBody. Morphing body elements may dispatch turbo:morph,
6340
+ * turbo:before-morph-element, turbo:before-morph-attribute, and
6341
+ * turbo:morph-element events.
6342
+ *
6343
+ * @param currentBody HTMLBodyElement destination of morphing changes
6344
+ * @param newBody HTMLBodyElement source of morphing changes
6345
+ */
6346
+ function morphBodyElements(currentBody, newBody) {
6347
+ MorphingPageRenderer.renderElement(currentBody, newBody);
6348
+ }
6349
+
6350
+ /**
6351
+ * Morph the child elements of the currentFrame based on the child elements of
6352
+ * the newFrame. Morphing turbo-frame elements may dispatch turbo:before-frame-morph,
6353
+ * turbo:before-morph-element, turbo:before-morph-attribute, and
6354
+ * turbo:morph-element events.
6355
+ *
6356
+ * @param currentFrame FrameElement destination of morphing children changes
6357
+ * @param newFrame FrameElement source of morphing children changes
6358
+ */
6359
+ function morphTurboFrameElements(currentFrame, newFrame) {
6360
+ MorphingFrameRenderer.renderElement(currentFrame, newFrame);
6361
+ }
6362
+
5802
6363
  var Turbo = /*#__PURE__*/Object.freeze({
5803
6364
  __proto__: null,
5804
6365
  navigator: navigator$1,
@@ -5818,7 +6379,11 @@ var Turbo = /*#__PURE__*/Object.freeze({
5818
6379
  clearCache: clearCache,
5819
6380
  setProgressBarDelay: setProgressBarDelay,
5820
6381
  setConfirmMethod: setConfirmMethod,
5821
- setFormMode: setFormMode
6382
+ setFormMode: setFormMode,
6383
+ morphBodyElements: morphBodyElements,
6384
+ morphTurboFrameElements: morphTurboFrameElements,
6385
+ morphChildren: morphChildren,
6386
+ morphElements: morphElements
5822
6387
  });
5823
6388
 
5824
6389
  class TurboFrameMissingError extends Error {}
@@ -6507,10 +7072,10 @@ class StreamElement extends HTMLElement {
6507
7072
  * Gets the list of duplicate children (i.e. those with the same ID)
6508
7073
  */
6509
7074
  get duplicateChildren() {
6510
- const existingChildren = this.targetElements.flatMap((e) => [...e.children]).filter((c) => !!c.id);
6511
- const newChildrenIds = [...(this.templateContent?.children || [])].filter((c) => !!c.id).map((c) => c.id);
7075
+ const existingChildren = this.targetElements.flatMap((e) => [...e.children]).filter((c) => !!c.getAttribute("id"));
7076
+ const newChildrenIds = [...(this.templateContent?.children || [])].filter((c) => !!c.getAttribute("id")).map((c) => c.getAttribute("id"));
6512
7077
 
6513
- return existingChildren.filter((c) => newChildrenIds.includes(c.id))
7078
+ return existingChildren.filter((c) => newChildrenIds.includes(c.getAttribute("id")))
6514
7079
  }
6515
7080
 
6516
7081
  /**
@@ -6693,4 +7258,4 @@ if (customElements.get("turbo-stream-source") === undefined) {
6693
7258
  window.Turbo = { ...Turbo, StreamActions };
6694
7259
  start();
6695
7260
 
6696
- export { FetchEnctype, FetchMethod, FetchRequest, FetchResponse, FrameElement, FrameLoadingStyle, FrameRenderer, PageRenderer, PageSnapshot, StreamActions, StreamElement, StreamSourceElement, cache, clearCache, config, connectStreamSource, disconnectStreamSource, fetchWithTurboHeaders as fetch, fetchEnctypeFromString, fetchMethodFromString, isSafe, navigator$1 as navigator, registerAdapter, renderStreamMessage, session, setConfirmMethod, setFormMode, setProgressBarDelay, start, visit };
7261
+ export { FetchEnctype, FetchMethod, FetchRequest, FetchResponse, FrameElement, FrameLoadingStyle, FrameRenderer, PageRenderer, PageSnapshot, StreamActions, StreamElement, StreamSourceElement, cache, clearCache, config, connectStreamSource, disconnectStreamSource, fetchWithTurboHeaders as fetch, fetchEnctypeFromString, fetchMethodFromString, isSafe, morphBodyElements, morphChildren, morphElements, morphTurboFrameElements, navigator$1 as navigator, registerAdapter, renderStreamMessage, session, setConfirmMethod, setFormMode, setProgressBarDelay, start, visit };