@hotwired/turbo 8.0.19 → 8.0.20

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,5 +1,5 @@
1
1
  /*!
2
- Turbo 8.0.18
2
+ Turbo 8.0.19
3
3
  Copyright © 2025 37signals LLC
4
4
  */
5
5
  /**
@@ -2139,6 +2139,7 @@ var Idiomorph = (function () {
2139
2139
  * @property {ConfigInternal['callbacks']} callbacks
2140
2140
  * @property {ConfigInternal['head']} head
2141
2141
  * @property {HTMLDivElement} pantry
2142
+ * @property {Element[]} activeElementAndParents
2142
2143
  */
2143
2144
 
2144
2145
  //=============================================================================
@@ -2214,14 +2215,6 @@ var Idiomorph = (function () {
2214
2215
  */
2215
2216
  function morphOuterHTML(ctx, oldNode, newNode) {
2216
2217
  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
2218
  morphChildren(
2226
2219
  ctx,
2227
2220
  oldParent,
@@ -2230,10 +2223,8 @@ var Idiomorph = (function () {
2230
2223
  oldNode, // start point for iteration
2231
2224
  oldNode.nextSibling, // end point for iteration
2232
2225
  );
2233
-
2234
- // return just the morphed nodes
2235
- childNodes = Array.from(oldParent.childNodes);
2236
- return childNodes.slice(index, childNodes.length - rightMargin);
2226
+ // this is safe even with siblings, because normalizeParent returns a SlicedParentNode if needed.
2227
+ return Array.from(oldParent.childNodes);
2237
2228
  }
2238
2229
 
2239
2230
  /**
@@ -2262,8 +2253,11 @@ var Idiomorph = (function () {
2262
2253
 
2263
2254
  const results = fn();
2264
2255
 
2265
- if (activeElementId && activeElementId !== document.activeElement?.id) {
2266
- activeElement = ctx.target.querySelector(`#${activeElementId}`);
2256
+ if (
2257
+ activeElementId &&
2258
+ activeElementId !== document.activeElement?.getAttribute("id")
2259
+ ) {
2260
+ activeElement = ctx.target.querySelector(`[id="${activeElementId}"]`);
2267
2261
  activeElement?.focus();
2268
2262
  }
2269
2263
  if (activeElement && !activeElement.selectionEnd && selectionEnd) {
@@ -2341,17 +2335,23 @@ var Idiomorph = (function () {
2341
2335
  }
2342
2336
 
2343
2337
  // 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,
2338
+ if (newChild instanceof Element) {
2339
+ // we can pretend the id is non-null because the next `.has` line will reject it if not
2340
+ const newChildId = /** @type {String} */ (
2341
+ newChild.getAttribute("id")
2351
2342
  );
2352
- morphNode(movedChild, newChild, ctx);
2353
- insertionPoint = movedChild.nextSibling;
2354
- continue;
2343
+ if (ctx.persistentIds.has(newChildId)) {
2344
+ // move it and all its children here and morph
2345
+ const movedChild = moveBeforeById(
2346
+ oldParent,
2347
+ newChildId,
2348
+ insertionPoint,
2349
+ ctx,
2350
+ );
2351
+ morphNode(movedChild, newChild, ctx);
2352
+ insertionPoint = movedChild.nextSibling;
2353
+ continue;
2354
+ }
2355
2355
  }
2356
2356
 
2357
2357
  // last resort: insert the new node from scratch
@@ -2461,7 +2461,8 @@ var Idiomorph = (function () {
2461
2461
 
2462
2462
  // if the current node contains active element, stop looking for better future matches,
2463
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;
2464
+ // @ts-ignore pretend cursor is Element rather than Node, we're just testing for array inclusion
2465
+ if (ctx.activeElementAndParents.includes(cursor)) break;
2465
2466
 
2466
2467
  cursor = cursor.nextSibling;
2467
2468
  }
@@ -2511,7 +2512,9 @@ var Idiomorph = (function () {
2511
2512
  // If oldElt has an `id` with possible state and it doesn't match newElt.id then avoid morphing.
2512
2513
  // We'll still match an anonymous node with an IDed newElt, though, because if it got this far,
2513
2514
  // its not persistent, and new nodes can't have any hidden state.
2514
- (!oldElt.id || oldElt.id === newElt.id)
2515
+ // We can't use .id because of form input shadowing, and we can't count on .getAttribute's presence because it could be a document-fragment
2516
+ (!oldElt.getAttribute?.("id") ||
2517
+ oldElt.getAttribute?.("id") === newElt.getAttribute?.("id"))
2515
2518
  );
2516
2519
  }
2517
2520
 
@@ -2575,8 +2578,11 @@ var Idiomorph = (function () {
2575
2578
  const target =
2576
2579
  /** @type {Element} - will always be found */
2577
2580
  (
2578
- ctx.target.querySelector(`#${id}`) ||
2579
- ctx.pantry.querySelector(`#${id}`)
2581
+ // ctx.target.id unsafe because of form input shadowing
2582
+ // ctx.target could be a document fragment which doesn't have `getAttribute`
2583
+ (ctx.target.getAttribute?.("id") === id && ctx.target) ||
2584
+ ctx.target.querySelector(`[id="${id}"]`) ||
2585
+ ctx.pantry.querySelector(`[id="${id}"]`)
2580
2586
  );
2581
2587
  removeElementFromAncestorsIdMaps(target, ctx);
2582
2588
  moveBefore(parentNode, target, after);
@@ -2592,7 +2598,8 @@ var Idiomorph = (function () {
2592
2598
  * @param {MorphContext} ctx
2593
2599
  */
2594
2600
  function removeElementFromAncestorsIdMaps(element, ctx) {
2595
- const id = element.id;
2601
+ // we know id is non-null String, because this function is only called on elements with ids
2602
+ const id = /** @type {String} */ (element.getAttribute("id"));
2596
2603
  /** @ts-ignore - safe to loop in this way **/
2597
2604
  while ((element = element.parentNode)) {
2598
2605
  let idSet = ctx.idMap.get(element);
@@ -3030,6 +3037,7 @@ var Idiomorph = (function () {
3030
3037
  idMap: idMap,
3031
3038
  persistentIds: persistentIds,
3032
3039
  pantry: createPantry(),
3040
+ activeElementAndParents: createActiveElementAndParents(oldNode),
3033
3041
  callbacks: mergedConfig.callbacks,
3034
3042
  head: mergedConfig.head,
3035
3043
  };
@@ -3070,6 +3078,24 @@ var Idiomorph = (function () {
3070
3078
  return pantry;
3071
3079
  }
3072
3080
 
3081
+ /**
3082
+ * @param {Element} oldNode
3083
+ * @returns {Element[]}
3084
+ */
3085
+ function createActiveElementAndParents(oldNode) {
3086
+ /** @type {Element[]} */
3087
+ let activeElementAndParents = [];
3088
+ let elt = document.activeElement;
3089
+ if (elt?.tagName !== "BODY" && oldNode.contains(elt)) {
3090
+ while (elt) {
3091
+ activeElementAndParents.push(elt);
3092
+ if (elt === oldNode) break;
3093
+ elt = elt.parentElement;
3094
+ }
3095
+ }
3096
+ return activeElementAndParents;
3097
+ }
3098
+
3073
3099
  /**
3074
3100
  * Returns all elements with an ID contained within the root element and its descendants
3075
3101
  *
@@ -3078,7 +3104,8 @@ var Idiomorph = (function () {
3078
3104
  */
3079
3105
  function findIdElements(root) {
3080
3106
  let elements = Array.from(root.querySelectorAll("[id]"));
3081
- if (root.id) {
3107
+ // root could be a document fragment which doesn't have `getAttribute`
3108
+ if (root.getAttribute?.("id")) {
3082
3109
  elements.push(root);
3083
3110
  }
3084
3111
  return elements;
@@ -3097,7 +3124,9 @@ var Idiomorph = (function () {
3097
3124
  */
3098
3125
  function populateIdMapWithTree(idMap, persistentIds, root, elements) {
3099
3126
  for (const elt of elements) {
3100
- if (persistentIds.has(elt.id)) {
3127
+ // we can pretend id is non-null String, because the .has line will reject it immediately if not
3128
+ const id = /** @type {String} */ (elt.getAttribute("id"));
3129
+ if (persistentIds.has(id)) {
3101
3130
  /** @type {Element|null} */
3102
3131
  let current = elt;
3103
3132
  // walk up the parent hierarchy of that element, adding the id
@@ -3109,7 +3138,7 @@ var Idiomorph = (function () {
3109
3138
  idSet = new Set();
3110
3139
  idMap.set(current, idSet);
3111
3140
  }
3112
- idSet.add(elt.id);
3141
+ idSet.add(id);
3113
3142
 
3114
3143
  if (current === root) break;
3115
3144
  current = current.parentElement;
@@ -3223,8 +3252,9 @@ var Idiomorph = (function () {
3223
3252
  if (newContent.parentNode) {
3224
3253
  // we can't use the parent directly because newContent may have siblings
3225
3254
  // 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);
3255
+ // so instead we create a fake parent node that only sees a slice of its children.
3256
+ /** @type {Element} */
3257
+ return /** @type {any} */ (new SlicedParentNode(newContent));
3228
3258
  } else {
3229
3259
  // a single node is added as a child to a dummy parent
3230
3260
  const dummyParent = document.createElement("div");
@@ -3243,33 +3273,78 @@ var Idiomorph = (function () {
3243
3273
  }
3244
3274
 
3245
3275
  /**
3246
- * Creates a fake duck-typed parent element to wrap a single node, without actually reparenting it.
3276
+ * A fake duck-typed parent element to wrap a single node, without actually reparenting it.
3277
+ * This is useful because the node may have siblings that we don't want in the morph, and it may also be moved
3278
+ * or replaced with one or more elements during the morph. This class effectively allows us a window into
3279
+ * a slice of a node's children.
3247
3280
  * "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
3281
  */
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
- );
3282
+ class SlicedParentNode {
3283
+ /** @param {Node} node */
3284
+ constructor(node) {
3285
+ this.originalNode = node;
3286
+ this.realParentNode = /** @type {Element} */ (node.parentNode);
3287
+ this.previousSibling = node.previousSibling;
3288
+ this.nextSibling = node.nextSibling;
3289
+ }
3290
+
3291
+ /** @returns {Node[]} */
3292
+ get childNodes() {
3293
+ // return slice of realParent's current childNodes, based on previousSibling and nextSibling
3294
+ const nodes = [];
3295
+ let cursor = this.previousSibling
3296
+ ? this.previousSibling.nextSibling
3297
+ : this.realParentNode.firstChild;
3298
+ while (cursor && cursor != this.nextSibling) {
3299
+ nodes.push(cursor);
3300
+ cursor = cursor.nextSibling;
3301
+ }
3302
+ return nodes;
3303
+ }
3304
+
3305
+ /**
3306
+ * @param {string} selector
3307
+ * @returns {Element[]}
3308
+ */
3309
+ querySelectorAll(selector) {
3310
+ return this.childNodes.reduce((results, node) => {
3311
+ if (node instanceof Element) {
3312
+ if (node.matches(selector)) results.push(node);
3313
+ const nodeList = node.querySelectorAll(selector);
3314
+ for (let i = 0; i < nodeList.length; i++) {
3315
+ results.push(nodeList[i]);
3316
+ }
3317
+ }
3318
+ return results;
3319
+ }, /** @type {Element[]} */ ([]));
3320
+ }
3321
+
3322
+ /**
3323
+ * @param {Node} node
3324
+ * @param {Node} referenceNode
3325
+ * @returns {Node}
3326
+ */
3327
+ insertBefore(node, referenceNode) {
3328
+ return this.realParentNode.insertBefore(node, referenceNode);
3329
+ }
3330
+
3331
+ /**
3332
+ * @param {Node} node
3333
+ * @param {Node} referenceNode
3334
+ * @returns {Node}
3335
+ */
3336
+ moveBefore(node, referenceNode) {
3337
+ // @ts-ignore - use new moveBefore feature
3338
+ return this.realParentNode.moveBefore(node, referenceNode);
3339
+ }
3340
+
3341
+ /**
3342
+ * for later use with populateIdMapWithTree to halt upwards iteration
3343
+ * @returns {Node}
3344
+ */
3345
+ get __idiomorphRoot() {
3346
+ return this.originalNode;
3347
+ }
3273
3348
  }
3274
3349
 
3275
3350
  /**
@@ -3364,16 +3439,18 @@ function morphChildren(currentElement, newElement, options = {}) {
3364
3439
 
3365
3440
  function shouldRefreshFrameWithMorphing(currentFrame, newFrame) {
3366
3441
  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"))) &&
3442
+ currentFrame.shouldReloadWithMorph && (!newFrame || areFramesCompatibleForRefreshing(currentFrame, newFrame)) &&
3374
3443
  !currentFrame.closest("[data-turbo-permanent]")
3375
3444
  }
3376
3445
 
3446
+ function areFramesCompatibleForRefreshing(currentFrame, newFrame) {
3447
+ // newFrame cannot yet be an instance of FrameElement because custom
3448
+ // elements don't get initialized until they're attached to the DOM, so
3449
+ // test its Element#nodeName instead
3450
+ return newFrame instanceof Element && newFrame.nodeName === "TURBO-FRAME" && currentFrame.id === newFrame.id &&
3451
+ (!newFrame.getAttribute("src") || urlsAreEqual(currentFrame.src, newFrame.getAttribute("src")))
3452
+ }
3453
+
3377
3454
  function closestFrameReloadableWithMorphing(node) {
3378
3455
  return node.parentElement.closest("turbo-frame[src][refresh=morph]")
3379
3456
  }
@@ -1,5 +1,5 @@
1
1
  /*!
2
- Turbo 8.0.18
2
+ Turbo 8.0.19
3
3
  Copyright © 2025 37signals LLC
4
4
  */
5
5
  (function (global, factory) {
@@ -2145,6 +2145,7 @@ Copyright © 2025 37signals LLC
2145
2145
  * @property {ConfigInternal['callbacks']} callbacks
2146
2146
  * @property {ConfigInternal['head']} head
2147
2147
  * @property {HTMLDivElement} pantry
2148
+ * @property {Element[]} activeElementAndParents
2148
2149
  */
2149
2150
 
2150
2151
  //=============================================================================
@@ -2220,14 +2221,6 @@ Copyright © 2025 37signals LLC
2220
2221
  */
2221
2222
  function morphOuterHTML(ctx, oldNode, newNode) {
2222
2223
  const oldParent = normalizeParent(oldNode);
2223
-
2224
- // basis for calulating which nodes were morphed
2225
- // since there may be unmorphed sibling nodes
2226
- let childNodes = Array.from(oldParent.childNodes);
2227
- const index = childNodes.indexOf(oldNode);
2228
- // how many elements are to the right of the oldNode
2229
- const rightMargin = childNodes.length - (index + 1);
2230
-
2231
2224
  morphChildren(
2232
2225
  ctx,
2233
2226
  oldParent,
@@ -2236,10 +2229,8 @@ Copyright © 2025 37signals LLC
2236
2229
  oldNode, // start point for iteration
2237
2230
  oldNode.nextSibling, // end point for iteration
2238
2231
  );
2239
-
2240
- // return just the morphed nodes
2241
- childNodes = Array.from(oldParent.childNodes);
2242
- return childNodes.slice(index, childNodes.length - rightMargin);
2232
+ // this is safe even with siblings, because normalizeParent returns a SlicedParentNode if needed.
2233
+ return Array.from(oldParent.childNodes);
2243
2234
  }
2244
2235
 
2245
2236
  /**
@@ -2268,8 +2259,11 @@ Copyright © 2025 37signals LLC
2268
2259
 
2269
2260
  const results = fn();
2270
2261
 
2271
- if (activeElementId && activeElementId !== document.activeElement?.id) {
2272
- activeElement = ctx.target.querySelector(`#${activeElementId}`);
2262
+ if (
2263
+ activeElementId &&
2264
+ activeElementId !== document.activeElement?.getAttribute("id")
2265
+ ) {
2266
+ activeElement = ctx.target.querySelector(`[id="${activeElementId}"]`);
2273
2267
  activeElement?.focus();
2274
2268
  }
2275
2269
  if (activeElement && !activeElement.selectionEnd && selectionEnd) {
@@ -2347,17 +2341,23 @@ Copyright © 2025 37signals LLC
2347
2341
  }
2348
2342
 
2349
2343
  // if the matching node is elsewhere in the original content
2350
- if (newChild instanceof Element && ctx.persistentIds.has(newChild.id)) {
2351
- // move it and all its children here and morph
2352
- const movedChild = moveBeforeById(
2353
- oldParent,
2354
- newChild.id,
2355
- insertionPoint,
2356
- ctx,
2344
+ if (newChild instanceof Element) {
2345
+ // we can pretend the id is non-null because the next `.has` line will reject it if not
2346
+ const newChildId = /** @type {String} */ (
2347
+ newChild.getAttribute("id")
2357
2348
  );
2358
- morphNode(movedChild, newChild, ctx);
2359
- insertionPoint = movedChild.nextSibling;
2360
- continue;
2349
+ if (ctx.persistentIds.has(newChildId)) {
2350
+ // move it and all its children here and morph
2351
+ const movedChild = moveBeforeById(
2352
+ oldParent,
2353
+ newChildId,
2354
+ insertionPoint,
2355
+ ctx,
2356
+ );
2357
+ morphNode(movedChild, newChild, ctx);
2358
+ insertionPoint = movedChild.nextSibling;
2359
+ continue;
2360
+ }
2361
2361
  }
2362
2362
 
2363
2363
  // last resort: insert the new node from scratch
@@ -2467,7 +2467,8 @@ Copyright © 2025 37signals LLC
2467
2467
 
2468
2468
  // if the current node contains active element, stop looking for better future matches,
2469
2469
  // because if one is found, this node will be moved to the pantry, reparenting it and thus losing focus
2470
- if (cursor.contains(document.activeElement)) break;
2470
+ // @ts-ignore pretend cursor is Element rather than Node, we're just testing for array inclusion
2471
+ if (ctx.activeElementAndParents.includes(cursor)) break;
2471
2472
 
2472
2473
  cursor = cursor.nextSibling;
2473
2474
  }
@@ -2517,7 +2518,9 @@ Copyright © 2025 37signals LLC
2517
2518
  // If oldElt has an `id` with possible state and it doesn't match newElt.id then avoid morphing.
2518
2519
  // We'll still match an anonymous node with an IDed newElt, though, because if it got this far,
2519
2520
  // its not persistent, and new nodes can't have any hidden state.
2520
- (!oldElt.id || oldElt.id === newElt.id)
2521
+ // We can't use .id because of form input shadowing, and we can't count on .getAttribute's presence because it could be a document-fragment
2522
+ (!oldElt.getAttribute?.("id") ||
2523
+ oldElt.getAttribute?.("id") === newElt.getAttribute?.("id"))
2521
2524
  );
2522
2525
  }
2523
2526
 
@@ -2581,8 +2584,11 @@ Copyright © 2025 37signals LLC
2581
2584
  const target =
2582
2585
  /** @type {Element} - will always be found */
2583
2586
  (
2584
- ctx.target.querySelector(`#${id}`) ||
2585
- ctx.pantry.querySelector(`#${id}`)
2587
+ // ctx.target.id unsafe because of form input shadowing
2588
+ // ctx.target could be a document fragment which doesn't have `getAttribute`
2589
+ (ctx.target.getAttribute?.("id") === id && ctx.target) ||
2590
+ ctx.target.querySelector(`[id="${id}"]`) ||
2591
+ ctx.pantry.querySelector(`[id="${id}"]`)
2586
2592
  );
2587
2593
  removeElementFromAncestorsIdMaps(target, ctx);
2588
2594
  moveBefore(parentNode, target, after);
@@ -2598,7 +2604,8 @@ Copyright © 2025 37signals LLC
2598
2604
  * @param {MorphContext} ctx
2599
2605
  */
2600
2606
  function removeElementFromAncestorsIdMaps(element, ctx) {
2601
- const id = element.id;
2607
+ // we know id is non-null String, because this function is only called on elements with ids
2608
+ const id = /** @type {String} */ (element.getAttribute("id"));
2602
2609
  /** @ts-ignore - safe to loop in this way **/
2603
2610
  while ((element = element.parentNode)) {
2604
2611
  let idSet = ctx.idMap.get(element);
@@ -3036,6 +3043,7 @@ Copyright © 2025 37signals LLC
3036
3043
  idMap: idMap,
3037
3044
  persistentIds: persistentIds,
3038
3045
  pantry: createPantry(),
3046
+ activeElementAndParents: createActiveElementAndParents(oldNode),
3039
3047
  callbacks: mergedConfig.callbacks,
3040
3048
  head: mergedConfig.head,
3041
3049
  };
@@ -3076,6 +3084,24 @@ Copyright © 2025 37signals LLC
3076
3084
  return pantry;
3077
3085
  }
3078
3086
 
3087
+ /**
3088
+ * @param {Element} oldNode
3089
+ * @returns {Element[]}
3090
+ */
3091
+ function createActiveElementAndParents(oldNode) {
3092
+ /** @type {Element[]} */
3093
+ let activeElementAndParents = [];
3094
+ let elt = document.activeElement;
3095
+ if (elt?.tagName !== "BODY" && oldNode.contains(elt)) {
3096
+ while (elt) {
3097
+ activeElementAndParents.push(elt);
3098
+ if (elt === oldNode) break;
3099
+ elt = elt.parentElement;
3100
+ }
3101
+ }
3102
+ return activeElementAndParents;
3103
+ }
3104
+
3079
3105
  /**
3080
3106
  * Returns all elements with an ID contained within the root element and its descendants
3081
3107
  *
@@ -3084,7 +3110,8 @@ Copyright © 2025 37signals LLC
3084
3110
  */
3085
3111
  function findIdElements(root) {
3086
3112
  let elements = Array.from(root.querySelectorAll("[id]"));
3087
- if (root.id) {
3113
+ // root could be a document fragment which doesn't have `getAttribute`
3114
+ if (root.getAttribute?.("id")) {
3088
3115
  elements.push(root);
3089
3116
  }
3090
3117
  return elements;
@@ -3103,7 +3130,9 @@ Copyright © 2025 37signals LLC
3103
3130
  */
3104
3131
  function populateIdMapWithTree(idMap, persistentIds, root, elements) {
3105
3132
  for (const elt of elements) {
3106
- if (persistentIds.has(elt.id)) {
3133
+ // we can pretend id is non-null String, because the .has line will reject it immediately if not
3134
+ const id = /** @type {String} */ (elt.getAttribute("id"));
3135
+ if (persistentIds.has(id)) {
3107
3136
  /** @type {Element|null} */
3108
3137
  let current = elt;
3109
3138
  // walk up the parent hierarchy of that element, adding the id
@@ -3115,7 +3144,7 @@ Copyright © 2025 37signals LLC
3115
3144
  idSet = new Set();
3116
3145
  idMap.set(current, idSet);
3117
3146
  }
3118
- idSet.add(elt.id);
3147
+ idSet.add(id);
3119
3148
 
3120
3149
  if (current === root) break;
3121
3150
  current = current.parentElement;
@@ -3229,8 +3258,9 @@ Copyright © 2025 37signals LLC
3229
3258
  if (newContent.parentNode) {
3230
3259
  // we can't use the parent directly because newContent may have siblings
3231
3260
  // that we don't want in the morph, and reparenting might be expensive (TODO is it?),
3232
- // so we create a duck-typed parent node instead.
3233
- return createDuckTypedParent(newContent);
3261
+ // so instead we create a fake parent node that only sees a slice of its children.
3262
+ /** @type {Element} */
3263
+ return /** @type {any} */ (new SlicedParentNode(newContent));
3234
3264
  } else {
3235
3265
  // a single node is added as a child to a dummy parent
3236
3266
  const dummyParent = document.createElement("div");
@@ -3249,33 +3279,78 @@ Copyright © 2025 37signals LLC
3249
3279
  }
3250
3280
 
3251
3281
  /**
3252
- * Creates a fake duck-typed parent element to wrap a single node, without actually reparenting it.
3282
+ * A fake duck-typed parent element to wrap a single node, without actually reparenting it.
3283
+ * This is useful because the node may have siblings that we don't want in the morph, and it may also be moved
3284
+ * or replaced with one or more elements during the morph. This class effectively allows us a window into
3285
+ * a slice of a node's children.
3253
3286
  * "If it walks like a duck, and quacks like a duck, then it must be a duck!" -- James Whitcomb Riley (1849–1916)
3254
- *
3255
- * @param {Node} newContent
3256
- * @returns {Element}
3257
3287
  */
3258
- function createDuckTypedParent(newContent) {
3259
- return /** @type {Element} */ (
3260
- /** @type {unknown} */ ({
3261
- childNodes: [newContent],
3262
- /** @ts-ignore - cover your eyes for a minute, tsc */
3263
- querySelectorAll: (s) => {
3264
- /** @ts-ignore */
3265
- const elements = newContent.querySelectorAll(s);
3266
- /** @ts-ignore */
3267
- return newContent.matches(s) ? [newContent, ...elements] : elements;
3268
- },
3269
- /** @ts-ignore */
3270
- insertBefore: (n, r) => newContent.parentNode.insertBefore(n, r),
3271
- /** @ts-ignore */
3272
- moveBefore: (n, r) => newContent.parentNode.moveBefore(n, r),
3273
- // for later use with populateIdMapWithTree to halt upwards iteration
3274
- get __idiomorphRoot() {
3275
- return newContent;
3276
- },
3277
- })
3278
- );
3288
+ class SlicedParentNode {
3289
+ /** @param {Node} node */
3290
+ constructor(node) {
3291
+ this.originalNode = node;
3292
+ this.realParentNode = /** @type {Element} */ (node.parentNode);
3293
+ this.previousSibling = node.previousSibling;
3294
+ this.nextSibling = node.nextSibling;
3295
+ }
3296
+
3297
+ /** @returns {Node[]} */
3298
+ get childNodes() {
3299
+ // return slice of realParent's current childNodes, based on previousSibling and nextSibling
3300
+ const nodes = [];
3301
+ let cursor = this.previousSibling
3302
+ ? this.previousSibling.nextSibling
3303
+ : this.realParentNode.firstChild;
3304
+ while (cursor && cursor != this.nextSibling) {
3305
+ nodes.push(cursor);
3306
+ cursor = cursor.nextSibling;
3307
+ }
3308
+ return nodes;
3309
+ }
3310
+
3311
+ /**
3312
+ * @param {string} selector
3313
+ * @returns {Element[]}
3314
+ */
3315
+ querySelectorAll(selector) {
3316
+ return this.childNodes.reduce((results, node) => {
3317
+ if (node instanceof Element) {
3318
+ if (node.matches(selector)) results.push(node);
3319
+ const nodeList = node.querySelectorAll(selector);
3320
+ for (let i = 0; i < nodeList.length; i++) {
3321
+ results.push(nodeList[i]);
3322
+ }
3323
+ }
3324
+ return results;
3325
+ }, /** @type {Element[]} */ ([]));
3326
+ }
3327
+
3328
+ /**
3329
+ * @param {Node} node
3330
+ * @param {Node} referenceNode
3331
+ * @returns {Node}
3332
+ */
3333
+ insertBefore(node, referenceNode) {
3334
+ return this.realParentNode.insertBefore(node, referenceNode);
3335
+ }
3336
+
3337
+ /**
3338
+ * @param {Node} node
3339
+ * @param {Node} referenceNode
3340
+ * @returns {Node}
3341
+ */
3342
+ moveBefore(node, referenceNode) {
3343
+ // @ts-ignore - use new moveBefore feature
3344
+ return this.realParentNode.moveBefore(node, referenceNode);
3345
+ }
3346
+
3347
+ /**
3348
+ * for later use with populateIdMapWithTree to halt upwards iteration
3349
+ * @returns {Node}
3350
+ */
3351
+ get __idiomorphRoot() {
3352
+ return this.originalNode;
3353
+ }
3279
3354
  }
3280
3355
 
3281
3356
  /**
@@ -3370,16 +3445,18 @@ Copyright © 2025 37signals LLC
3370
3445
 
3371
3446
  function shouldRefreshFrameWithMorphing(currentFrame, newFrame) {
3372
3447
  return currentFrame instanceof FrameElement &&
3373
- // newFrame cannot yet be an instance of FrameElement because custom
3374
- // elements don't get initialized until they're attached to the DOM, so
3375
- // test its Element#nodeName instead
3376
- newFrame instanceof Element && newFrame.nodeName === "TURBO-FRAME" &&
3377
- currentFrame.shouldReloadWithMorph &&
3378
- currentFrame.id === newFrame.id &&
3379
- (!newFrame.getAttribute("src") || urlsAreEqual(currentFrame.src, newFrame.getAttribute("src"))) &&
3448
+ currentFrame.shouldReloadWithMorph && (!newFrame || areFramesCompatibleForRefreshing(currentFrame, newFrame)) &&
3380
3449
  !currentFrame.closest("[data-turbo-permanent]")
3381
3450
  }
3382
3451
 
3452
+ function areFramesCompatibleForRefreshing(currentFrame, newFrame) {
3453
+ // newFrame cannot yet be an instance of FrameElement because custom
3454
+ // elements don't get initialized until they're attached to the DOM, so
3455
+ // test its Element#nodeName instead
3456
+ return newFrame instanceof Element && newFrame.nodeName === "TURBO-FRAME" && currentFrame.id === newFrame.id &&
3457
+ (!newFrame.getAttribute("src") || urlsAreEqual(currentFrame.src, newFrame.getAttribute("src")))
3458
+ }
3459
+
3383
3460
  function closestFrameReloadableWithMorphing(node) {
3384
3461
  return node.parentElement.closest("turbo-frame[src][refresh=morph]")
3385
3462
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotwired/turbo",
3
- "version": "8.0.19",
3
+ "version": "8.0.20",
4
4
  "description": "The speed of a single-page web application without having to write any JavaScript",
5
5
  "module": "dist/turbo.es2017-esm.js",
6
6
  "main": "dist/turbo.es2017-umd.js",