@hotwired/turbo 8.0.22 → 8.0.23

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.21
2
+ Turbo 8.0.23
3
3
  Copyright © 2026 37signals LLC
4
4
  */
5
5
  const FrameLoadingStyle = {
@@ -2119,6 +2119,7 @@ var Idiomorph = (function () {
2119
2119
  * @property {ConfigInternal['callbacks']} callbacks
2120
2120
  * @property {ConfigInternal['head']} head
2121
2121
  * @property {HTMLDivElement} pantry
2122
+ * @property {Element[]} activeElementAndParents
2122
2123
  */
2123
2124
 
2124
2125
  //=============================================================================
@@ -2194,14 +2195,6 @@ var Idiomorph = (function () {
2194
2195
  */
2195
2196
  function morphOuterHTML(ctx, oldNode, newNode) {
2196
2197
  const oldParent = normalizeParent(oldNode);
2197
-
2198
- // basis for calulating which nodes were morphed
2199
- // since there may be unmorphed sibling nodes
2200
- let childNodes = Array.from(oldParent.childNodes);
2201
- const index = childNodes.indexOf(oldNode);
2202
- // how many elements are to the right of the oldNode
2203
- const rightMargin = childNodes.length - (index + 1);
2204
-
2205
2198
  morphChildren(
2206
2199
  ctx,
2207
2200
  oldParent,
@@ -2210,10 +2203,8 @@ var Idiomorph = (function () {
2210
2203
  oldNode, // start point for iteration
2211
2204
  oldNode.nextSibling, // end point for iteration
2212
2205
  );
2213
-
2214
- // return just the morphed nodes
2215
- childNodes = Array.from(oldParent.childNodes);
2216
- return childNodes.slice(index, childNodes.length - rightMargin);
2206
+ // this is safe even with siblings, because normalizeParent returns a SlicedParentNode if needed.
2207
+ return Array.from(oldParent.childNodes);
2217
2208
  }
2218
2209
 
2219
2210
  /**
@@ -2242,8 +2233,11 @@ var Idiomorph = (function () {
2242
2233
 
2243
2234
  const results = fn();
2244
2235
 
2245
- if (activeElementId && activeElementId !== document.activeElement?.id) {
2246
- activeElement = ctx.target.querySelector(`#${activeElementId}`);
2236
+ if (
2237
+ activeElementId &&
2238
+ activeElementId !== document.activeElement?.getAttribute("id")
2239
+ ) {
2240
+ activeElement = ctx.target.querySelector(`[id="${activeElementId}"]`);
2247
2241
  activeElement?.focus();
2248
2242
  }
2249
2243
  if (activeElement && !activeElement.selectionEnd && selectionEnd) {
@@ -2321,17 +2315,23 @@ var Idiomorph = (function () {
2321
2315
  }
2322
2316
 
2323
2317
  // if the matching node is elsewhere in the original content
2324
- if (newChild instanceof Element && ctx.persistentIds.has(newChild.id)) {
2325
- // move it and all its children here and morph
2326
- const movedChild = moveBeforeById(
2327
- oldParent,
2328
- newChild.id,
2329
- insertionPoint,
2330
- ctx,
2318
+ if (newChild instanceof Element) {
2319
+ // we can pretend the id is non-null because the next `.has` line will reject it if not
2320
+ const newChildId = /** @type {String} */ (
2321
+ newChild.getAttribute("id")
2331
2322
  );
2332
- morphNode(movedChild, newChild, ctx);
2333
- insertionPoint = movedChild.nextSibling;
2334
- continue;
2323
+ if (ctx.persistentIds.has(newChildId)) {
2324
+ // move it and all its children here and morph
2325
+ const movedChild = moveBeforeById(
2326
+ oldParent,
2327
+ newChildId,
2328
+ insertionPoint,
2329
+ ctx,
2330
+ );
2331
+ morphNode(movedChild, newChild, ctx);
2332
+ insertionPoint = movedChild.nextSibling;
2333
+ continue;
2334
+ }
2335
2335
  }
2336
2336
 
2337
2337
  // last resort: insert the new node from scratch
@@ -2441,7 +2441,8 @@ var Idiomorph = (function () {
2441
2441
 
2442
2442
  // if the current node contains active element, stop looking for better future matches,
2443
2443
  // because if one is found, this node will be moved to the pantry, reparenting it and thus losing focus
2444
- if (cursor.contains(document.activeElement)) break;
2444
+ // @ts-ignore pretend cursor is Element rather than Node, we're just testing for array inclusion
2445
+ if (ctx.activeElementAndParents.includes(cursor)) break;
2445
2446
 
2446
2447
  cursor = cursor.nextSibling;
2447
2448
  }
@@ -2491,7 +2492,9 @@ var Idiomorph = (function () {
2491
2492
  // If oldElt has an `id` with possible state and it doesn't match newElt.id then avoid morphing.
2492
2493
  // We'll still match an anonymous node with an IDed newElt, though, because if it got this far,
2493
2494
  // its not persistent, and new nodes can't have any hidden state.
2494
- (!oldElt.id || oldElt.id === newElt.id)
2495
+ // 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
2496
+ (!oldElt.getAttribute?.("id") ||
2497
+ oldElt.getAttribute?.("id") === newElt.getAttribute?.("id"))
2495
2498
  );
2496
2499
  }
2497
2500
 
@@ -2555,8 +2558,11 @@ var Idiomorph = (function () {
2555
2558
  const target =
2556
2559
  /** @type {Element} - will always be found */
2557
2560
  (
2558
- ctx.target.querySelector(`#${id}`) ||
2559
- ctx.pantry.querySelector(`#${id}`)
2561
+ // ctx.target.id unsafe because of form input shadowing
2562
+ // ctx.target could be a document fragment which doesn't have `getAttribute`
2563
+ (ctx.target.getAttribute?.("id") === id && ctx.target) ||
2564
+ ctx.target.querySelector(`[id="${id}"]`) ||
2565
+ ctx.pantry.querySelector(`[id="${id}"]`)
2560
2566
  );
2561
2567
  removeElementFromAncestorsIdMaps(target, ctx);
2562
2568
  moveBefore(parentNode, target, after);
@@ -2572,7 +2578,8 @@ var Idiomorph = (function () {
2572
2578
  * @param {MorphContext} ctx
2573
2579
  */
2574
2580
  function removeElementFromAncestorsIdMaps(element, ctx) {
2575
- const id = element.id;
2581
+ // we know id is non-null String, because this function is only called on elements with ids
2582
+ const id = /** @type {String} */ (element.getAttribute("id"));
2576
2583
  /** @ts-ignore - safe to loop in this way **/
2577
2584
  while ((element = element.parentNode)) {
2578
2585
  let idSet = ctx.idMap.get(element);
@@ -3010,6 +3017,7 @@ var Idiomorph = (function () {
3010
3017
  idMap: idMap,
3011
3018
  persistentIds: persistentIds,
3012
3019
  pantry: createPantry(),
3020
+ activeElementAndParents: createActiveElementAndParents(oldNode),
3013
3021
  callbacks: mergedConfig.callbacks,
3014
3022
  head: mergedConfig.head,
3015
3023
  };
@@ -3050,6 +3058,24 @@ var Idiomorph = (function () {
3050
3058
  return pantry;
3051
3059
  }
3052
3060
 
3061
+ /**
3062
+ * @param {Element} oldNode
3063
+ * @returns {Element[]}
3064
+ */
3065
+ function createActiveElementAndParents(oldNode) {
3066
+ /** @type {Element[]} */
3067
+ let activeElementAndParents = [];
3068
+ let elt = document.activeElement;
3069
+ if (elt?.tagName !== "BODY" && oldNode.contains(elt)) {
3070
+ while (elt) {
3071
+ activeElementAndParents.push(elt);
3072
+ if (elt === oldNode) break;
3073
+ elt = elt.parentElement;
3074
+ }
3075
+ }
3076
+ return activeElementAndParents;
3077
+ }
3078
+
3053
3079
  /**
3054
3080
  * Returns all elements with an ID contained within the root element and its descendants
3055
3081
  *
@@ -3058,7 +3084,8 @@ var Idiomorph = (function () {
3058
3084
  */
3059
3085
  function findIdElements(root) {
3060
3086
  let elements = Array.from(root.querySelectorAll("[id]"));
3061
- if (root.id) {
3087
+ // root could be a document fragment which doesn't have `getAttribute`
3088
+ if (root.getAttribute?.("id")) {
3062
3089
  elements.push(root);
3063
3090
  }
3064
3091
  return elements;
@@ -3077,7 +3104,9 @@ var Idiomorph = (function () {
3077
3104
  */
3078
3105
  function populateIdMapWithTree(idMap, persistentIds, root, elements) {
3079
3106
  for (const elt of elements) {
3080
- if (persistentIds.has(elt.id)) {
3107
+ // we can pretend id is non-null String, because the .has line will reject it immediately if not
3108
+ const id = /** @type {String} */ (elt.getAttribute("id"));
3109
+ if (persistentIds.has(id)) {
3081
3110
  /** @type {Element|null} */
3082
3111
  let current = elt;
3083
3112
  // walk up the parent hierarchy of that element, adding the id
@@ -3089,7 +3118,7 @@ var Idiomorph = (function () {
3089
3118
  idSet = new Set();
3090
3119
  idMap.set(current, idSet);
3091
3120
  }
3092
- idSet.add(elt.id);
3121
+ idSet.add(id);
3093
3122
 
3094
3123
  if (current === root) break;
3095
3124
  current = current.parentElement;
@@ -3203,8 +3232,9 @@ var Idiomorph = (function () {
3203
3232
  if (newContent.parentNode) {
3204
3233
  // we can't use the parent directly because newContent may have siblings
3205
3234
  // that we don't want in the morph, and reparenting might be expensive (TODO is it?),
3206
- // so we create a duck-typed parent node instead.
3207
- return createDuckTypedParent(newContent);
3235
+ // so instead we create a fake parent node that only sees a slice of its children.
3236
+ /** @type {Element} */
3237
+ return /** @type {any} */ (new SlicedParentNode(newContent));
3208
3238
  } else {
3209
3239
  // a single node is added as a child to a dummy parent
3210
3240
  const dummyParent = document.createElement("div");
@@ -3223,33 +3253,78 @@ var Idiomorph = (function () {
3223
3253
  }
3224
3254
 
3225
3255
  /**
3226
- * Creates a fake duck-typed parent element to wrap a single node, without actually reparenting it.
3256
+ * A fake duck-typed parent element to wrap a single node, without actually reparenting it.
3257
+ * This is useful because the node may have siblings that we don't want in the morph, and it may also be moved
3258
+ * or replaced with one or more elements during the morph. This class effectively allows us a window into
3259
+ * a slice of a node's children.
3227
3260
  * "If it walks like a duck, and quacks like a duck, then it must be a duck!" -- James Whitcomb Riley (1849–1916)
3228
- *
3229
- * @param {Node} newContent
3230
- * @returns {Element}
3231
3261
  */
3232
- function createDuckTypedParent(newContent) {
3233
- return /** @type {Element} */ (
3234
- /** @type {unknown} */ ({
3235
- childNodes: [newContent],
3236
- /** @ts-ignore - cover your eyes for a minute, tsc */
3237
- querySelectorAll: (s) => {
3238
- /** @ts-ignore */
3239
- const elements = newContent.querySelectorAll(s);
3240
- /** @ts-ignore */
3241
- return newContent.matches(s) ? [newContent, ...elements] : elements;
3242
- },
3243
- /** @ts-ignore */
3244
- insertBefore: (n, r) => newContent.parentNode.insertBefore(n, r),
3245
- /** @ts-ignore */
3246
- moveBefore: (n, r) => newContent.parentNode.moveBefore(n, r),
3247
- // for later use with populateIdMapWithTree to halt upwards iteration
3248
- get __idiomorphRoot() {
3249
- return newContent;
3250
- },
3251
- })
3252
- );
3262
+ class SlicedParentNode {
3263
+ /** @param {Node} node */
3264
+ constructor(node) {
3265
+ this.originalNode = node;
3266
+ this.realParentNode = /** @type {Element} */ (node.parentNode);
3267
+ this.previousSibling = node.previousSibling;
3268
+ this.nextSibling = node.nextSibling;
3269
+ }
3270
+
3271
+ /** @returns {Node[]} */
3272
+ get childNodes() {
3273
+ // return slice of realParent's current childNodes, based on previousSibling and nextSibling
3274
+ const nodes = [];
3275
+ let cursor = this.previousSibling
3276
+ ? this.previousSibling.nextSibling
3277
+ : this.realParentNode.firstChild;
3278
+ while (cursor && cursor != this.nextSibling) {
3279
+ nodes.push(cursor);
3280
+ cursor = cursor.nextSibling;
3281
+ }
3282
+ return nodes;
3283
+ }
3284
+
3285
+ /**
3286
+ * @param {string} selector
3287
+ * @returns {Element[]}
3288
+ */
3289
+ querySelectorAll(selector) {
3290
+ return this.childNodes.reduce((results, node) => {
3291
+ if (node instanceof Element) {
3292
+ if (node.matches(selector)) results.push(node);
3293
+ const nodeList = node.querySelectorAll(selector);
3294
+ for (let i = 0; i < nodeList.length; i++) {
3295
+ results.push(nodeList[i]);
3296
+ }
3297
+ }
3298
+ return results;
3299
+ }, /** @type {Element[]} */ ([]));
3300
+ }
3301
+
3302
+ /**
3303
+ * @param {Node} node
3304
+ * @param {Node} referenceNode
3305
+ * @returns {Node}
3306
+ */
3307
+ insertBefore(node, referenceNode) {
3308
+ return this.realParentNode.insertBefore(node, referenceNode);
3309
+ }
3310
+
3311
+ /**
3312
+ * @param {Node} node
3313
+ * @param {Node} referenceNode
3314
+ * @returns {Node}
3315
+ */
3316
+ moveBefore(node, referenceNode) {
3317
+ // @ts-ignore - use new moveBefore feature
3318
+ return this.realParentNode.moveBefore(node, referenceNode);
3319
+ }
3320
+
3321
+ /**
3322
+ * for later use with populateIdMapWithTree to halt upwards iteration
3323
+ * @returns {Node}
3324
+ */
3325
+ get __idiomorphRoot() {
3326
+ return this.originalNode;
3327
+ }
3253
3328
  }
3254
3329
 
3255
3330
  /**
@@ -1,5 +1,5 @@
1
1
  /*!
2
- Turbo 8.0.21
2
+ Turbo 8.0.23
3
3
  Copyright © 2026 37signals LLC
4
4
  */
5
5
  (function (global, factory) {
@@ -2125,6 +2125,7 @@ Copyright © 2026 37signals LLC
2125
2125
  * @property {ConfigInternal['callbacks']} callbacks
2126
2126
  * @property {ConfigInternal['head']} head
2127
2127
  * @property {HTMLDivElement} pantry
2128
+ * @property {Element[]} activeElementAndParents
2128
2129
  */
2129
2130
 
2130
2131
  //=============================================================================
@@ -2200,14 +2201,6 @@ Copyright © 2026 37signals LLC
2200
2201
  */
2201
2202
  function morphOuterHTML(ctx, oldNode, newNode) {
2202
2203
  const oldParent = normalizeParent(oldNode);
2203
-
2204
- // basis for calulating which nodes were morphed
2205
- // since there may be unmorphed sibling nodes
2206
- let childNodes = Array.from(oldParent.childNodes);
2207
- const index = childNodes.indexOf(oldNode);
2208
- // how many elements are to the right of the oldNode
2209
- const rightMargin = childNodes.length - (index + 1);
2210
-
2211
2204
  morphChildren(
2212
2205
  ctx,
2213
2206
  oldParent,
@@ -2216,10 +2209,8 @@ Copyright © 2026 37signals LLC
2216
2209
  oldNode, // start point for iteration
2217
2210
  oldNode.nextSibling, // end point for iteration
2218
2211
  );
2219
-
2220
- // return just the morphed nodes
2221
- childNodes = Array.from(oldParent.childNodes);
2222
- return childNodes.slice(index, childNodes.length - rightMargin);
2212
+ // this is safe even with siblings, because normalizeParent returns a SlicedParentNode if needed.
2213
+ return Array.from(oldParent.childNodes);
2223
2214
  }
2224
2215
 
2225
2216
  /**
@@ -2248,8 +2239,11 @@ Copyright © 2026 37signals LLC
2248
2239
 
2249
2240
  const results = fn();
2250
2241
 
2251
- if (activeElementId && activeElementId !== document.activeElement?.id) {
2252
- activeElement = ctx.target.querySelector(`#${activeElementId}`);
2242
+ if (
2243
+ activeElementId &&
2244
+ activeElementId !== document.activeElement?.getAttribute("id")
2245
+ ) {
2246
+ activeElement = ctx.target.querySelector(`[id="${activeElementId}"]`);
2253
2247
  activeElement?.focus();
2254
2248
  }
2255
2249
  if (activeElement && !activeElement.selectionEnd && selectionEnd) {
@@ -2327,17 +2321,23 @@ Copyright © 2026 37signals LLC
2327
2321
  }
2328
2322
 
2329
2323
  // if the matching node is elsewhere in the original content
2330
- if (newChild instanceof Element && ctx.persistentIds.has(newChild.id)) {
2331
- // move it and all its children here and morph
2332
- const movedChild = moveBeforeById(
2333
- oldParent,
2334
- newChild.id,
2335
- insertionPoint,
2336
- ctx,
2324
+ if (newChild instanceof Element) {
2325
+ // we can pretend the id is non-null because the next `.has` line will reject it if not
2326
+ const newChildId = /** @type {String} */ (
2327
+ newChild.getAttribute("id")
2337
2328
  );
2338
- morphNode(movedChild, newChild, ctx);
2339
- insertionPoint = movedChild.nextSibling;
2340
- continue;
2329
+ if (ctx.persistentIds.has(newChildId)) {
2330
+ // move it and all its children here and morph
2331
+ const movedChild = moveBeforeById(
2332
+ oldParent,
2333
+ newChildId,
2334
+ insertionPoint,
2335
+ ctx,
2336
+ );
2337
+ morphNode(movedChild, newChild, ctx);
2338
+ insertionPoint = movedChild.nextSibling;
2339
+ continue;
2340
+ }
2341
2341
  }
2342
2342
 
2343
2343
  // last resort: insert the new node from scratch
@@ -2447,7 +2447,8 @@ Copyright © 2026 37signals LLC
2447
2447
 
2448
2448
  // if the current node contains active element, stop looking for better future matches,
2449
2449
  // because if one is found, this node will be moved to the pantry, reparenting it and thus losing focus
2450
- if (cursor.contains(document.activeElement)) break;
2450
+ // @ts-ignore pretend cursor is Element rather than Node, we're just testing for array inclusion
2451
+ if (ctx.activeElementAndParents.includes(cursor)) break;
2451
2452
 
2452
2453
  cursor = cursor.nextSibling;
2453
2454
  }
@@ -2497,7 +2498,9 @@ Copyright © 2026 37signals LLC
2497
2498
  // If oldElt has an `id` with possible state and it doesn't match newElt.id then avoid morphing.
2498
2499
  // We'll still match an anonymous node with an IDed newElt, though, because if it got this far,
2499
2500
  // its not persistent, and new nodes can't have any hidden state.
2500
- (!oldElt.id || oldElt.id === newElt.id)
2501
+ // 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
2502
+ (!oldElt.getAttribute?.("id") ||
2503
+ oldElt.getAttribute?.("id") === newElt.getAttribute?.("id"))
2501
2504
  );
2502
2505
  }
2503
2506
 
@@ -2561,8 +2564,11 @@ Copyright © 2026 37signals LLC
2561
2564
  const target =
2562
2565
  /** @type {Element} - will always be found */
2563
2566
  (
2564
- ctx.target.querySelector(`#${id}`) ||
2565
- ctx.pantry.querySelector(`#${id}`)
2567
+ // ctx.target.id unsafe because of form input shadowing
2568
+ // ctx.target could be a document fragment which doesn't have `getAttribute`
2569
+ (ctx.target.getAttribute?.("id") === id && ctx.target) ||
2570
+ ctx.target.querySelector(`[id="${id}"]`) ||
2571
+ ctx.pantry.querySelector(`[id="${id}"]`)
2566
2572
  );
2567
2573
  removeElementFromAncestorsIdMaps(target, ctx);
2568
2574
  moveBefore(parentNode, target, after);
@@ -2578,7 +2584,8 @@ Copyright © 2026 37signals LLC
2578
2584
  * @param {MorphContext} ctx
2579
2585
  */
2580
2586
  function removeElementFromAncestorsIdMaps(element, ctx) {
2581
- const id = element.id;
2587
+ // we know id is non-null String, because this function is only called on elements with ids
2588
+ const id = /** @type {String} */ (element.getAttribute("id"));
2582
2589
  /** @ts-ignore - safe to loop in this way **/
2583
2590
  while ((element = element.parentNode)) {
2584
2591
  let idSet = ctx.idMap.get(element);
@@ -3016,6 +3023,7 @@ Copyright © 2026 37signals LLC
3016
3023
  idMap: idMap,
3017
3024
  persistentIds: persistentIds,
3018
3025
  pantry: createPantry(),
3026
+ activeElementAndParents: createActiveElementAndParents(oldNode),
3019
3027
  callbacks: mergedConfig.callbacks,
3020
3028
  head: mergedConfig.head,
3021
3029
  };
@@ -3056,6 +3064,24 @@ Copyright © 2026 37signals LLC
3056
3064
  return pantry;
3057
3065
  }
3058
3066
 
3067
+ /**
3068
+ * @param {Element} oldNode
3069
+ * @returns {Element[]}
3070
+ */
3071
+ function createActiveElementAndParents(oldNode) {
3072
+ /** @type {Element[]} */
3073
+ let activeElementAndParents = [];
3074
+ let elt = document.activeElement;
3075
+ if (elt?.tagName !== "BODY" && oldNode.contains(elt)) {
3076
+ while (elt) {
3077
+ activeElementAndParents.push(elt);
3078
+ if (elt === oldNode) break;
3079
+ elt = elt.parentElement;
3080
+ }
3081
+ }
3082
+ return activeElementAndParents;
3083
+ }
3084
+
3059
3085
  /**
3060
3086
  * Returns all elements with an ID contained within the root element and its descendants
3061
3087
  *
@@ -3064,7 +3090,8 @@ Copyright © 2026 37signals LLC
3064
3090
  */
3065
3091
  function findIdElements(root) {
3066
3092
  let elements = Array.from(root.querySelectorAll("[id]"));
3067
- if (root.id) {
3093
+ // root could be a document fragment which doesn't have `getAttribute`
3094
+ if (root.getAttribute?.("id")) {
3068
3095
  elements.push(root);
3069
3096
  }
3070
3097
  return elements;
@@ -3083,7 +3110,9 @@ Copyright © 2026 37signals LLC
3083
3110
  */
3084
3111
  function populateIdMapWithTree(idMap, persistentIds, root, elements) {
3085
3112
  for (const elt of elements) {
3086
- if (persistentIds.has(elt.id)) {
3113
+ // we can pretend id is non-null String, because the .has line will reject it immediately if not
3114
+ const id = /** @type {String} */ (elt.getAttribute("id"));
3115
+ if (persistentIds.has(id)) {
3087
3116
  /** @type {Element|null} */
3088
3117
  let current = elt;
3089
3118
  // walk up the parent hierarchy of that element, adding the id
@@ -3095,7 +3124,7 @@ Copyright © 2026 37signals LLC
3095
3124
  idSet = new Set();
3096
3125
  idMap.set(current, idSet);
3097
3126
  }
3098
- idSet.add(elt.id);
3127
+ idSet.add(id);
3099
3128
 
3100
3129
  if (current === root) break;
3101
3130
  current = current.parentElement;
@@ -3209,8 +3238,9 @@ Copyright © 2026 37signals LLC
3209
3238
  if (newContent.parentNode) {
3210
3239
  // we can't use the parent directly because newContent may have siblings
3211
3240
  // that we don't want in the morph, and reparenting might be expensive (TODO is it?),
3212
- // so we create a duck-typed parent node instead.
3213
- return createDuckTypedParent(newContent);
3241
+ // so instead we create a fake parent node that only sees a slice of its children.
3242
+ /** @type {Element} */
3243
+ return /** @type {any} */ (new SlicedParentNode(newContent));
3214
3244
  } else {
3215
3245
  // a single node is added as a child to a dummy parent
3216
3246
  const dummyParent = document.createElement("div");
@@ -3229,33 +3259,78 @@ Copyright © 2026 37signals LLC
3229
3259
  }
3230
3260
 
3231
3261
  /**
3232
- * Creates a fake duck-typed parent element to wrap a single node, without actually reparenting it.
3262
+ * A fake duck-typed parent element to wrap a single node, without actually reparenting it.
3263
+ * This is useful because the node may have siblings that we don't want in the morph, and it may also be moved
3264
+ * or replaced with one or more elements during the morph. This class effectively allows us a window into
3265
+ * a slice of a node's children.
3233
3266
  * "If it walks like a duck, and quacks like a duck, then it must be a duck!" -- James Whitcomb Riley (1849–1916)
3234
- *
3235
- * @param {Node} newContent
3236
- * @returns {Element}
3237
3267
  */
3238
- function createDuckTypedParent(newContent) {
3239
- return /** @type {Element} */ (
3240
- /** @type {unknown} */ ({
3241
- childNodes: [newContent],
3242
- /** @ts-ignore - cover your eyes for a minute, tsc */
3243
- querySelectorAll: (s) => {
3244
- /** @ts-ignore */
3245
- const elements = newContent.querySelectorAll(s);
3246
- /** @ts-ignore */
3247
- return newContent.matches(s) ? [newContent, ...elements] : elements;
3248
- },
3249
- /** @ts-ignore */
3250
- insertBefore: (n, r) => newContent.parentNode.insertBefore(n, r),
3251
- /** @ts-ignore */
3252
- moveBefore: (n, r) => newContent.parentNode.moveBefore(n, r),
3253
- // for later use with populateIdMapWithTree to halt upwards iteration
3254
- get __idiomorphRoot() {
3255
- return newContent;
3256
- },
3257
- })
3258
- );
3268
+ class SlicedParentNode {
3269
+ /** @param {Node} node */
3270
+ constructor(node) {
3271
+ this.originalNode = node;
3272
+ this.realParentNode = /** @type {Element} */ (node.parentNode);
3273
+ this.previousSibling = node.previousSibling;
3274
+ this.nextSibling = node.nextSibling;
3275
+ }
3276
+
3277
+ /** @returns {Node[]} */
3278
+ get childNodes() {
3279
+ // return slice of realParent's current childNodes, based on previousSibling and nextSibling
3280
+ const nodes = [];
3281
+ let cursor = this.previousSibling
3282
+ ? this.previousSibling.nextSibling
3283
+ : this.realParentNode.firstChild;
3284
+ while (cursor && cursor != this.nextSibling) {
3285
+ nodes.push(cursor);
3286
+ cursor = cursor.nextSibling;
3287
+ }
3288
+ return nodes;
3289
+ }
3290
+
3291
+ /**
3292
+ * @param {string} selector
3293
+ * @returns {Element[]}
3294
+ */
3295
+ querySelectorAll(selector) {
3296
+ return this.childNodes.reduce((results, node) => {
3297
+ if (node instanceof Element) {
3298
+ if (node.matches(selector)) results.push(node);
3299
+ const nodeList = node.querySelectorAll(selector);
3300
+ for (let i = 0; i < nodeList.length; i++) {
3301
+ results.push(nodeList[i]);
3302
+ }
3303
+ }
3304
+ return results;
3305
+ }, /** @type {Element[]} */ ([]));
3306
+ }
3307
+
3308
+ /**
3309
+ * @param {Node} node
3310
+ * @param {Node} referenceNode
3311
+ * @returns {Node}
3312
+ */
3313
+ insertBefore(node, referenceNode) {
3314
+ return this.realParentNode.insertBefore(node, referenceNode);
3315
+ }
3316
+
3317
+ /**
3318
+ * @param {Node} node
3319
+ * @param {Node} referenceNode
3320
+ * @returns {Node}
3321
+ */
3322
+ moveBefore(node, referenceNode) {
3323
+ // @ts-ignore - use new moveBefore feature
3324
+ return this.realParentNode.moveBefore(node, referenceNode);
3325
+ }
3326
+
3327
+ /**
3328
+ * for later use with populateIdMapWithTree to halt upwards iteration
3329
+ * @returns {Node}
3330
+ */
3331
+ get __idiomorphRoot() {
3332
+ return this.originalNode;
3333
+ }
3259
3334
  }
3260
3335
 
3261
3336
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotwired/turbo",
3
- "version": "8.0.22",
3
+ "version": "8.0.23",
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",