@hotwired/turbo 8.0.19 → 8.0.21

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.18
3
- Copyright © 2025 37signals LLC
2
+ Turbo 8.0.21
3
+ Copyright © 2026 37signals LLC
4
4
  */
5
5
  (function (global, factory) {
6
6
  typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
@@ -8,103 +8,6 @@ Copyright © 2025 37signals LLC
8
8
  (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.Turbo = {}));
9
9
  })(this, (function (exports) { 'use strict';
10
10
 
11
- /**
12
- * The MIT License (MIT)
13
- *
14
- * Copyright (c) 2019 Javan Makhmali
15
- *
16
- * Permission is hereby granted, free of charge, to any person obtaining a copy
17
- * of this software and associated documentation files (the "Software"), to deal
18
- * in the Software without restriction, including without limitation the rights
19
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
20
- * copies of the Software, and to permit persons to whom the Software is
21
- * furnished to do so, subject to the following conditions:
22
- *
23
- * The above copyright notice and this permission notice shall be included in
24
- * all copies or substantial portions of the Software.
25
- *
26
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
27
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
28
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
29
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
30
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
31
- * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
32
- * THE SOFTWARE.
33
- */
34
-
35
- (function (prototype) {
36
- if (typeof prototype.requestSubmit == "function") return
37
-
38
- prototype.requestSubmit = function (submitter) {
39
- if (submitter) {
40
- validateSubmitter(submitter, this);
41
- submitter.click();
42
- } else {
43
- submitter = document.createElement("input");
44
- submitter.type = "submit";
45
- submitter.hidden = true;
46
- this.appendChild(submitter);
47
- submitter.click();
48
- this.removeChild(submitter);
49
- }
50
- };
51
-
52
- function validateSubmitter(submitter, form) {
53
- submitter instanceof HTMLElement || raise(TypeError, "parameter 1 is not of type 'HTMLElement'");
54
- submitter.type == "submit" || raise(TypeError, "The specified element is not a submit button");
55
- submitter.form == form ||
56
- raise(DOMException, "The specified element is not owned by this form element", "NotFoundError");
57
- }
58
-
59
- function raise(errorConstructor, message, name) {
60
- throw new errorConstructor("Failed to execute 'requestSubmit' on 'HTMLFormElement': " + message + ".", name)
61
- }
62
- })(HTMLFormElement.prototype);
63
-
64
- const submittersByForm = new WeakMap();
65
-
66
- function findSubmitterFromClickTarget(target) {
67
- const element = target instanceof Element ? target : target instanceof Node ? target.parentElement : null;
68
- const candidate = element ? element.closest("input, button") : null;
69
- return candidate?.type == "submit" ? candidate : null
70
- }
71
-
72
- function clickCaptured(event) {
73
- const submitter = findSubmitterFromClickTarget(event.target);
74
-
75
- if (submitter && submitter.form) {
76
- submittersByForm.set(submitter.form, submitter);
77
- }
78
- }
79
-
80
- (function () {
81
- if ("submitter" in Event.prototype) return
82
-
83
- let prototype = window.Event.prototype;
84
- // Certain versions of Safari 15 have a bug where they won't
85
- // populate the submitter. This hurts TurboDrive's enable/disable detection.
86
- // See https://bugs.webkit.org/show_bug.cgi?id=229660
87
- if ("SubmitEvent" in window) {
88
- const prototypeOfSubmitEvent = window.SubmitEvent.prototype;
89
-
90
- if (/Apple Computer/.test(navigator.vendor) && !("submitter" in prototypeOfSubmitEvent)) {
91
- prototype = prototypeOfSubmitEvent;
92
- } else {
93
- return // polyfill not needed
94
- }
95
- }
96
-
97
- addEventListener("click", clickCaptured, true);
98
-
99
- Object.defineProperty(prototype, "submitter", {
100
- get() {
101
- if (this.type == "submit" && this.target instanceof HTMLFormElement) {
102
- return submittersByForm.get(this.target)
103
- }
104
- }
105
- });
106
- })();
107
-
108
11
  const FrameLoadingStyle = {
109
12
  eager: "eager",
110
13
  lazy: "lazy"
@@ -380,10 +283,6 @@ Copyright © 2025 37signals LLC
380
283
  return new Promise((resolve) => setTimeout(() => resolve(), 0))
381
284
  }
382
285
 
383
- function nextMicrotask() {
384
- return Promise.resolve()
385
- }
386
-
387
286
  function parseHTMLDocument(html = "") {
388
287
  return new DOMParser().parseFromString(html, "text/html")
389
288
  }
@@ -412,7 +311,7 @@ Copyright © 2025 37signals LLC
412
311
  } else if (i == 19) {
413
312
  return (Math.floor(Math.random() * 4) + 8).toString(16)
414
313
  } else {
415
- return Math.floor(Math.random() * 15).toString(16)
314
+ return Math.floor(Math.random() * 16).toString(16)
416
315
  }
417
316
  })
418
317
  .join("")
@@ -564,14 +463,13 @@ Copyright © 2025 37signals LLC
564
463
  const link = findClosestRecursively(target, "a[href], a[xlink\\:href]");
565
464
 
566
465
  if (!link) return null
466
+ if (link.href.startsWith("#")) return null
567
467
  if (link.hasAttribute("download")) return null
568
- if (link.hasAttribute("target") && link.target !== "_self") return null
569
468
 
570
- return link
571
- }
469
+ const linkTarget = link.getAttribute("target");
470
+ if (linkTarget && linkTarget !== "_self") return null
572
471
 
573
- function getLocationForLink(link) {
574
- return expandURL(link.getAttribute("href") || "")
472
+ return link
575
473
  }
576
474
 
577
475
  function debounce(fn, delay) {
@@ -662,6 +560,10 @@ Copyright © 2025 37signals LLC
662
560
  return isPrefixedBy(location, rootLocation) && !config.drive.unvisitableExtensions.has(getExtension(location))
663
561
  }
664
562
 
563
+ function getLocationForLink(link) {
564
+ return expandURL(link.getAttribute("href") || "")
565
+ }
566
+
665
567
  function getRequestURL(url) {
666
568
  const anchor = getAnchor(url);
667
569
  return anchor != null ? url.href.slice(0, -(anchor.length + 1)) : url.href
@@ -1077,35 +979,113 @@ Copyright © 2025 37signals LLC
1077
979
  return fragment
1078
980
  }
1079
981
 
1080
- const PREFETCH_DELAY = 100;
982
+ const identity = key => key;
1081
983
 
1082
- class PrefetchCache {
1083
- #prefetchTimeout = null
1084
- #prefetched = null
984
+ class LRUCache {
985
+ keys = []
986
+ entries = {}
987
+ #toCacheKey
988
+
989
+ constructor(size, toCacheKey = identity) {
990
+ this.size = size;
991
+ this.#toCacheKey = toCacheKey;
992
+ }
993
+
994
+ has(key) {
995
+ return this.#toCacheKey(key) in this.entries
996
+ }
997
+
998
+ get(key) {
999
+ if (this.has(key)) {
1000
+ const entry = this.read(key);
1001
+ this.touch(key);
1002
+ return entry
1003
+ }
1004
+ }
1005
+
1006
+ put(key, entry) {
1007
+ this.write(key, entry);
1008
+ this.touch(key);
1009
+ return entry
1010
+ }
1011
+
1012
+ clear() {
1013
+ for (const key of Object.keys(this.entries)) {
1014
+ this.evict(key);
1015
+ }
1016
+ }
1017
+
1018
+ // Private
1019
+
1020
+ read(key) {
1021
+ return this.entries[this.#toCacheKey(key)]
1022
+ }
1023
+
1024
+ write(key, entry) {
1025
+ this.entries[this.#toCacheKey(key)] = entry;
1026
+ }
1085
1027
 
1086
- get(url) {
1087
- if (this.#prefetched && this.#prefetched.url === url && this.#prefetched.expire > Date.now()) {
1088
- return this.#prefetched.request
1028
+ touch(key) {
1029
+ key = this.#toCacheKey(key);
1030
+ const index = this.keys.indexOf(key);
1031
+ if (index > -1) this.keys.splice(index, 1);
1032
+ this.keys.unshift(key);
1033
+ this.trim();
1034
+ }
1035
+
1036
+ trim() {
1037
+ for (const key of this.keys.splice(this.size)) {
1038
+ this.evict(key);
1089
1039
  }
1090
1040
  }
1091
1041
 
1092
- setLater(url, request, ttl) {
1093
- this.clear();
1042
+ evict(key) {
1043
+ delete this.entries[key];
1044
+ }
1045
+ }
1046
+
1047
+ const PREFETCH_DELAY = 100;
1094
1048
 
1049
+ class PrefetchCache extends LRUCache {
1050
+ #prefetchTimeout = null
1051
+ #maxAges = {}
1052
+
1053
+ constructor(size = 1, prefetchDelay = PREFETCH_DELAY) {
1054
+ super(size, toCacheKey);
1055
+ this.prefetchDelay = prefetchDelay;
1056
+ }
1057
+
1058
+ putLater(url, request, ttl) {
1095
1059
  this.#prefetchTimeout = setTimeout(() => {
1096
1060
  request.perform();
1097
- this.set(url, request, ttl);
1061
+ this.put(url, request, ttl);
1098
1062
  this.#prefetchTimeout = null;
1099
- }, PREFETCH_DELAY);
1063
+ }, this.prefetchDelay);
1100
1064
  }
1101
1065
 
1102
- set(url, request, ttl) {
1103
- this.#prefetched = { url, request, expire: new Date(new Date().getTime() + ttl) };
1066
+ put(url, request, ttl = cacheTtl) {
1067
+ super.put(url, request);
1068
+ this.#maxAges[toCacheKey(url)] = new Date(new Date().getTime() + ttl);
1104
1069
  }
1105
1070
 
1106
1071
  clear() {
1072
+ super.clear();
1107
1073
  if (this.#prefetchTimeout) clearTimeout(this.#prefetchTimeout);
1108
- this.#prefetched = null;
1074
+ }
1075
+
1076
+ evict(key) {
1077
+ super.evict(key);
1078
+ delete this.#maxAges[key];
1079
+ }
1080
+
1081
+ has(key) {
1082
+ if (super.has(key)) {
1083
+ const maxAge = this.#maxAges[toCacheKey(key)];
1084
+
1085
+ return maxAge && maxAge > Date.now()
1086
+ } else {
1087
+ return false
1088
+ }
1109
1089
  }
1110
1090
  }
1111
1091
 
@@ -2145,6 +2125,7 @@ Copyright © 2025 37signals LLC
2145
2125
  * @property {ConfigInternal['callbacks']} callbacks
2146
2126
  * @property {ConfigInternal['head']} head
2147
2127
  * @property {HTMLDivElement} pantry
2128
+ * @property {Element[]} activeElementAndParents
2148
2129
  */
2149
2130
 
2150
2131
  //=============================================================================
@@ -2220,14 +2201,6 @@ Copyright © 2025 37signals LLC
2220
2201
  */
2221
2202
  function morphOuterHTML(ctx, oldNode, newNode) {
2222
2203
  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
2204
  morphChildren(
2232
2205
  ctx,
2233
2206
  oldParent,
@@ -2236,10 +2209,8 @@ Copyright © 2025 37signals LLC
2236
2209
  oldNode, // start point for iteration
2237
2210
  oldNode.nextSibling, // end point for iteration
2238
2211
  );
2239
-
2240
- // return just the morphed nodes
2241
- childNodes = Array.from(oldParent.childNodes);
2242
- 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);
2243
2214
  }
2244
2215
 
2245
2216
  /**
@@ -2268,8 +2239,11 @@ Copyright © 2025 37signals LLC
2268
2239
 
2269
2240
  const results = fn();
2270
2241
 
2271
- if (activeElementId && activeElementId !== document.activeElement?.id) {
2272
- activeElement = ctx.target.querySelector(`#${activeElementId}`);
2242
+ if (
2243
+ activeElementId &&
2244
+ activeElementId !== document.activeElement?.getAttribute("id")
2245
+ ) {
2246
+ activeElement = ctx.target.querySelector(`[id="${activeElementId}"]`);
2273
2247
  activeElement?.focus();
2274
2248
  }
2275
2249
  if (activeElement && !activeElement.selectionEnd && selectionEnd) {
@@ -2347,17 +2321,23 @@ Copyright © 2025 37signals LLC
2347
2321
  }
2348
2322
 
2349
2323
  // 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,
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")
2357
2328
  );
2358
- morphNode(movedChild, newChild, ctx);
2359
- insertionPoint = movedChild.nextSibling;
2360
- 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
+ }
2361
2341
  }
2362
2342
 
2363
2343
  // last resort: insert the new node from scratch
@@ -2467,7 +2447,8 @@ Copyright © 2025 37signals LLC
2467
2447
 
2468
2448
  // if the current node contains active element, stop looking for better future matches,
2469
2449
  // 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;
2450
+ // @ts-ignore pretend cursor is Element rather than Node, we're just testing for array inclusion
2451
+ if (ctx.activeElementAndParents.includes(cursor)) break;
2471
2452
 
2472
2453
  cursor = cursor.nextSibling;
2473
2454
  }
@@ -2517,7 +2498,9 @@ Copyright © 2025 37signals LLC
2517
2498
  // If oldElt has an `id` with possible state and it doesn't match newElt.id then avoid morphing.
2518
2499
  // We'll still match an anonymous node with an IDed newElt, though, because if it got this far,
2519
2500
  // its not persistent, and new nodes can't have any hidden state.
2520
- (!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"))
2521
2504
  );
2522
2505
  }
2523
2506
 
@@ -2581,8 +2564,11 @@ Copyright © 2025 37signals LLC
2581
2564
  const target =
2582
2565
  /** @type {Element} - will always be found */
2583
2566
  (
2584
- ctx.target.querySelector(`#${id}`) ||
2585
- 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}"]`)
2586
2572
  );
2587
2573
  removeElementFromAncestorsIdMaps(target, ctx);
2588
2574
  moveBefore(parentNode, target, after);
@@ -2598,7 +2584,8 @@ Copyright © 2025 37signals LLC
2598
2584
  * @param {MorphContext} ctx
2599
2585
  */
2600
2586
  function removeElementFromAncestorsIdMaps(element, ctx) {
2601
- 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"));
2602
2589
  /** @ts-ignore - safe to loop in this way **/
2603
2590
  while ((element = element.parentNode)) {
2604
2591
  let idSet = ctx.idMap.get(element);
@@ -3036,6 +3023,7 @@ Copyright © 2025 37signals LLC
3036
3023
  idMap: idMap,
3037
3024
  persistentIds: persistentIds,
3038
3025
  pantry: createPantry(),
3026
+ activeElementAndParents: createActiveElementAndParents(oldNode),
3039
3027
  callbacks: mergedConfig.callbacks,
3040
3028
  head: mergedConfig.head,
3041
3029
  };
@@ -3076,6 +3064,24 @@ Copyright © 2025 37signals LLC
3076
3064
  return pantry;
3077
3065
  }
3078
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
+
3079
3085
  /**
3080
3086
  * Returns all elements with an ID contained within the root element and its descendants
3081
3087
  *
@@ -3084,7 +3090,8 @@ Copyright © 2025 37signals LLC
3084
3090
  */
3085
3091
  function findIdElements(root) {
3086
3092
  let elements = Array.from(root.querySelectorAll("[id]"));
3087
- if (root.id) {
3093
+ // root could be a document fragment which doesn't have `getAttribute`
3094
+ if (root.getAttribute?.("id")) {
3088
3095
  elements.push(root);
3089
3096
  }
3090
3097
  return elements;
@@ -3103,7 +3110,9 @@ Copyright © 2025 37signals LLC
3103
3110
  */
3104
3111
  function populateIdMapWithTree(idMap, persistentIds, root, elements) {
3105
3112
  for (const elt of elements) {
3106
- 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)) {
3107
3116
  /** @type {Element|null} */
3108
3117
  let current = elt;
3109
3118
  // walk up the parent hierarchy of that element, adding the id
@@ -3115,7 +3124,7 @@ Copyright © 2025 37signals LLC
3115
3124
  idSet = new Set();
3116
3125
  idMap.set(current, idSet);
3117
3126
  }
3118
- idSet.add(elt.id);
3127
+ idSet.add(id);
3119
3128
 
3120
3129
  if (current === root) break;
3121
3130
  current = current.parentElement;
@@ -3229,8 +3238,9 @@ Copyright © 2025 37signals LLC
3229
3238
  if (newContent.parentNode) {
3230
3239
  // we can't use the parent directly because newContent may have siblings
3231
3240
  // 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);
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));
3234
3244
  } else {
3235
3245
  // a single node is added as a child to a dummy parent
3236
3246
  const dummyParent = document.createElement("div");
@@ -3249,33 +3259,78 @@ Copyright © 2025 37signals LLC
3249
3259
  }
3250
3260
 
3251
3261
  /**
3252
- * 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.
3253
3266
  * "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
3267
  */
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
- );
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
+ }
3279
3334
  }
3280
3335
 
3281
3336
  /**
@@ -3370,16 +3425,18 @@ Copyright © 2025 37signals LLC
3370
3425
 
3371
3426
  function shouldRefreshFrameWithMorphing(currentFrame, newFrame) {
3372
3427
  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"))) &&
3428
+ currentFrame.shouldReloadWithMorph && (!newFrame || areFramesCompatibleForRefreshing(currentFrame, newFrame)) &&
3380
3429
  !currentFrame.closest("[data-turbo-permanent]")
3381
3430
  }
3382
3431
 
3432
+ function areFramesCompatibleForRefreshing(currentFrame, newFrame) {
3433
+ // newFrame cannot yet be an instance of FrameElement because custom
3434
+ // elements don't get initialized until they're attached to the DOM, so
3435
+ // test its Element#nodeName instead
3436
+ return newFrame instanceof Element && newFrame.nodeName === "TURBO-FRAME" && currentFrame.id === newFrame.id &&
3437
+ (!newFrame.getAttribute("src") || urlsAreEqual(currentFrame.src, newFrame.getAttribute("src")))
3438
+ }
3439
+
3383
3440
  function closestFrameReloadableWithMorphing(node) {
3384
3441
  return node.parentElement.closest("turbo-frame[src][refresh=morph]")
3385
3442
  }
@@ -3731,6 +3788,10 @@ Copyright © 2025 37signals LLC
3731
3788
  clonedPasswordInput.value = "";
3732
3789
  }
3733
3790
 
3791
+ for (const clonedNoscriptElement of clonedElement.querySelectorAll("noscript")) {
3792
+ clonedNoscriptElement.remove();
3793
+ }
3794
+
3734
3795
  return new PageSnapshot(this.documentElement, clonedElement, this.headSnapshot)
3735
3796
  }
3736
3797
 
@@ -3738,6 +3799,10 @@ Copyright © 2025 37signals LLC
3738
3799
  return this.documentElement.getAttribute("lang")
3739
3800
  }
3740
3801
 
3802
+ get dir() {
3803
+ return this.documentElement.getAttribute("dir")
3804
+ }
3805
+
3741
3806
  get headElement() {
3742
3807
  return this.headSnapshot.element
3743
3808
  }
@@ -3768,12 +3833,12 @@ Copyright © 2025 37signals LLC
3768
3833
  return viewTransitionEnabled && !window.matchMedia("(prefers-reduced-motion: reduce)").matches
3769
3834
  }
3770
3835
 
3771
- get shouldMorphPage() {
3772
- return this.getSetting("refresh-method") === "morph"
3836
+ get refreshMethod() {
3837
+ return this.getSetting("refresh-method")
3773
3838
  }
3774
3839
 
3775
- get shouldPreserveScrollPosition() {
3776
- return this.getSetting("refresh-scroll") === "preserve"
3840
+ get refreshScroll() {
3841
+ return this.getSetting("refresh-scroll")
3777
3842
  }
3778
3843
 
3779
3844
  // Private
@@ -3812,7 +3877,8 @@ Copyright © 2025 37signals LLC
3812
3877
  willRender: true,
3813
3878
  updateHistory: true,
3814
3879
  shouldCacheSnapshot: true,
3815
- acceptsStreamResponse: false
3880
+ acceptsStreamResponse: false,
3881
+ refresh: {}
3816
3882
  };
3817
3883
 
3818
3884
  const TimingMetric = {
@@ -3872,7 +3938,8 @@ Copyright © 2025 37signals LLC
3872
3938
  updateHistory,
3873
3939
  shouldCacheSnapshot,
3874
3940
  acceptsStreamResponse,
3875
- direction
3941
+ direction,
3942
+ refresh
3876
3943
  } = {
3877
3944
  ...defaultOptions,
3878
3945
  ...options
@@ -3883,7 +3950,6 @@ Copyright © 2025 37signals LLC
3883
3950
  this.snapshot = snapshot;
3884
3951
  this.snapshotHTML = snapshotHTML;
3885
3952
  this.response = response;
3886
- this.isSamePage = this.delegate.locationWithActionIsSamePage(this.location, this.action);
3887
3953
  this.isPageRefresh = this.view.isPageRefresh(this);
3888
3954
  this.visitCachedSnapshot = visitCachedSnapshot;
3889
3955
  this.willRender = willRender;
@@ -3892,6 +3958,7 @@ Copyright © 2025 37signals LLC
3892
3958
  this.shouldCacheSnapshot = shouldCacheSnapshot;
3893
3959
  this.acceptsStreamResponse = acceptsStreamResponse;
3894
3960
  this.direction = direction || Direction[action];
3961
+ this.refresh = refresh;
3895
3962
  }
3896
3963
 
3897
3964
  get adapter() {
@@ -3910,10 +3977,6 @@ Copyright © 2025 37signals LLC
3910
3977
  return this.history.getRestorationDataForIdentifier(this.restorationIdentifier)
3911
3978
  }
3912
3979
 
3913
- get silent() {
3914
- return this.isSamePage
3915
- }
3916
-
3917
3980
  start() {
3918
3981
  if (this.state == VisitState.initialized) {
3919
3982
  this.recordTimingMetric(TimingMetric.visitStart);
@@ -4050,7 +4113,7 @@ Copyright © 2025 37signals LLC
4050
4113
  const isPreview = this.shouldIssueRequest();
4051
4114
  this.render(async () => {
4052
4115
  this.cacheSnapshot();
4053
- if (this.isSamePage || this.isPageRefresh) {
4116
+ if (this.isPageRefresh) {
4054
4117
  this.adapter.visitRendered(this);
4055
4118
  } else {
4056
4119
  if (this.view.renderPromise) await this.view.renderPromise;
@@ -4078,17 +4141,6 @@ Copyright © 2025 37signals LLC
4078
4141
  }
4079
4142
  }
4080
4143
 
4081
- goToSamePageAnchor() {
4082
- if (this.isSamePage) {
4083
- this.render(async () => {
4084
- this.cacheSnapshot();
4085
- this.performScroll();
4086
- this.changeHistory();
4087
- this.adapter.visitRendered(this);
4088
- });
4089
- }
4090
- }
4091
-
4092
4144
  // Fetch request delegate
4093
4145
 
4094
4146
  prepareRequest(request) {
@@ -4150,9 +4202,6 @@ Copyright © 2025 37signals LLC
4150
4202
  } else {
4151
4203
  this.scrollToAnchor() || this.view.scrollToTop();
4152
4204
  }
4153
- if (this.isSamePage) {
4154
- this.delegate.visitScrolledToSamePageLocation(this.view.lastRenderedLocation, this.location);
4155
- }
4156
4205
 
4157
4206
  this.scrolled = true;
4158
4207
  }
@@ -4191,9 +4240,7 @@ Copyright © 2025 37signals LLC
4191
4240
  }
4192
4241
 
4193
4242
  shouldIssueRequest() {
4194
- if (this.isSamePage) {
4195
- return false
4196
- } else if (this.action == "restore") {
4243
+ if (this.action == "restore") {
4197
4244
  return !this.hasCachedSnapshot()
4198
4245
  } else {
4199
4246
  return this.willRender
@@ -4257,7 +4304,6 @@ Copyright © 2025 37signals LLC
4257
4304
 
4258
4305
  visit.loadCachedSnapshot();
4259
4306
  visit.issueRequest();
4260
- visit.goToSamePageAnchor();
4261
4307
  }
4262
4308
 
4263
4309
  visitRequestStarted(visit) {
@@ -4374,7 +4420,6 @@ Copyright © 2025 37signals LLC
4374
4420
 
4375
4421
  class CacheObserver {
4376
4422
  selector = "[data-turbo-temporary]"
4377
- deprecatedSelector = "[data-turbo-cache=false]"
4378
4423
 
4379
4424
  started = false
4380
4425
 
@@ -4399,19 +4444,7 @@ Copyright © 2025 37signals LLC
4399
4444
  }
4400
4445
 
4401
4446
  get temporaryElements() {
4402
- return [...document.querySelectorAll(this.selector), ...this.temporaryElementsWithDeprecation]
4403
- }
4404
-
4405
- get temporaryElementsWithDeprecation() {
4406
- const elements = document.querySelectorAll(this.deprecatedSelector);
4407
-
4408
- if (elements.length) {
4409
- console.warn(
4410
- `The ${this.deprecatedSelector} selector is deprecated and will be removed in a future version. Use ${this.selector} instead.`
4411
- );
4412
- }
4413
-
4414
- return [...elements]
4447
+ return [...document.querySelectorAll(this.selector)]
4415
4448
  }
4416
4449
  }
4417
4450
 
@@ -4501,7 +4534,6 @@ Copyright © 2025 37signals LLC
4501
4534
  restorationIdentifier = uuid()
4502
4535
  restorationData = {}
4503
4536
  started = false
4504
- pageLoaded = false
4505
4537
  currentIndex = 0
4506
4538
 
4507
4539
  constructor(delegate) {
@@ -4511,7 +4543,6 @@ Copyright © 2025 37signals LLC
4511
4543
  start() {
4512
4544
  if (!this.started) {
4513
4545
  addEventListener("popstate", this.onPopState, false);
4514
- addEventListener("load", this.onPageLoad, false);
4515
4546
  this.currentIndex = history.state?.turbo?.restorationIndex || 0;
4516
4547
  this.started = true;
4517
4548
  this.replace(new URL(window.location.href));
@@ -4521,7 +4552,6 @@ Copyright © 2025 37signals LLC
4521
4552
  stop() {
4522
4553
  if (this.started) {
4523
4554
  removeEventListener("popstate", this.onPopState, false);
4524
- removeEventListener("load", this.onPageLoad, false);
4525
4555
  this.started = false;
4526
4556
  }
4527
4557
  }
@@ -4577,34 +4607,20 @@ Copyright © 2025 37signals LLC
4577
4607
  // Event handlers
4578
4608
 
4579
4609
  onPopState = (event) => {
4580
- if (this.shouldHandlePopState()) {
4581
- const { turbo } = event.state || {};
4582
- if (turbo) {
4583
- this.location = new URL(window.location.href);
4584
- const { restorationIdentifier, restorationIndex } = turbo;
4585
- this.restorationIdentifier = restorationIdentifier;
4586
- const direction = restorationIndex > this.currentIndex ? "forward" : "back";
4587
- this.delegate.historyPoppedToLocationWithRestorationIdentifierAndDirection(this.location, restorationIdentifier, direction);
4588
- this.currentIndex = restorationIndex;
4589
- }
4610
+ const { turbo } = event.state || {};
4611
+ this.location = new URL(window.location.href);
4612
+
4613
+ if (turbo) {
4614
+ const { restorationIdentifier, restorationIndex } = turbo;
4615
+ this.restorationIdentifier = restorationIdentifier;
4616
+ const direction = restorationIndex > this.currentIndex ? "forward" : "back";
4617
+ this.delegate.historyPoppedToLocationWithRestorationIdentifierAndDirection(this.location, restorationIdentifier, direction);
4618
+ this.currentIndex = restorationIndex;
4619
+ } else {
4620
+ this.currentIndex++;
4621
+ this.delegate.historyPoppedWithEmptyState(this.location);
4590
4622
  }
4591
4623
  }
4592
-
4593
- onPageLoad = async (_event) => {
4594
- await nextMicrotask();
4595
- this.pageLoaded = true;
4596
- }
4597
-
4598
- // Private
4599
-
4600
- shouldHandlePopState() {
4601
- // Safari dispatches a popstate event after window's load event, ignore it
4602
- return this.pageIsLoaded()
4603
- }
4604
-
4605
- pageIsLoaded() {
4606
- return this.pageLoaded || document.readyState == "complete"
4607
- }
4608
4624
  }
4609
4625
 
4610
4626
  class LinkPrefetchObserver {
@@ -4679,7 +4695,7 @@ Copyright © 2025 37signals LLC
4679
4695
 
4680
4696
  fetchRequest.fetchOptions.priority = "low";
4681
4697
 
4682
- prefetchCache.setLater(location.toString(), fetchRequest, this.#cacheTtl);
4698
+ prefetchCache.putLater(location, fetchRequest, this.#cacheTtl);
4683
4699
  }
4684
4700
  }
4685
4701
  }
@@ -4695,7 +4711,7 @@ Copyright © 2025 37signals LLC
4695
4711
 
4696
4712
  #tryToUsePrefetchedRequest = (event) => {
4697
4713
  if (event.target.tagName !== "FORM" && event.detail.fetchOptions.method === "GET") {
4698
- const cached = prefetchCache.get(event.detail.url.toString());
4714
+ const cached = prefetchCache.get(event.detail.url);
4699
4715
 
4700
4716
  if (cached) {
4701
4717
  // User clicked link, use cache response
@@ -4885,7 +4901,7 @@ Copyright © 2025 37signals LLC
4885
4901
  } else {
4886
4902
  await this.view.renderPage(snapshot, false, true, this.currentVisit);
4887
4903
  }
4888
- if(!snapshot.shouldPreserveScrollPosition) {
4904
+ if (snapshot.refreshScroll !== "preserve") {
4889
4905
  this.view.scrollToTop();
4890
4906
  }
4891
4907
  this.view.clearSnapshotCache();
@@ -4925,20 +4941,10 @@ Copyright © 2025 37signals LLC
4925
4941
  delete this.currentVisit;
4926
4942
  }
4927
4943
 
4944
+ // Same-page links are no longer handled with a Visit.
4945
+ // This method is still needed for Turbo Native adapters.
4928
4946
  locationWithActionIsSamePage(location, action) {
4929
- const anchor = getAnchor(location);
4930
- const currentAnchor = getAnchor(this.view.lastRenderedLocation);
4931
- const isRestorationToTop = action === "restore" && typeof anchor === "undefined";
4932
-
4933
- return (
4934
- action !== "replace" &&
4935
- getRequestURL(location) === getRequestURL(this.view.lastRenderedLocation) &&
4936
- (isRestorationToTop || (anchor != null && anchor !== currentAnchor))
4937
- )
4938
- }
4939
-
4940
- visitScrolledToSamePageLocation(oldURL, newURL) {
4941
- this.delegate.visitScrolledToSamePageLocation(oldURL, newURL);
4947
+ return false
4942
4948
  }
4943
4949
 
4944
4950
  // Visits
@@ -5331,13 +5337,18 @@ Copyright © 2025 37signals LLC
5331
5337
 
5332
5338
  #setLanguage() {
5333
5339
  const { documentElement } = this.currentSnapshot;
5334
- const { lang } = this.newSnapshot;
5340
+ const { dir, lang } = this.newSnapshot;
5335
5341
 
5336
5342
  if (lang) {
5337
5343
  documentElement.setAttribute("lang", lang);
5338
5344
  } else {
5339
5345
  documentElement.removeAttribute("lang");
5340
5346
  }
5347
+ if (dir) {
5348
+ documentElement.setAttribute("dir", dir);
5349
+ } else {
5350
+ documentElement.removeAttribute("dir");
5351
+ }
5341
5352
  }
5342
5353
 
5343
5354
  async mergeHead() {
@@ -5439,9 +5450,16 @@ Copyright © 2025 37signals LLC
5439
5450
 
5440
5451
  activateNewBody() {
5441
5452
  document.adoptNode(this.newElement);
5453
+ this.removeNoscriptElements();
5442
5454
  this.activateNewBodyScriptElements();
5443
5455
  }
5444
5456
 
5457
+ removeNoscriptElements() {
5458
+ for (const noscriptElement of this.newElement.querySelectorAll("noscript")) {
5459
+ noscriptElement.remove();
5460
+ }
5461
+ }
5462
+
5445
5463
  activateNewBodyScriptElements() {
5446
5464
  for (const inertScriptElement of this.newBodyScriptElements) {
5447
5465
  const activatedScriptElement = activateScriptElement(inertScriptElement);
@@ -5517,58 +5535,13 @@ Copyright © 2025 37signals LLC
5517
5535
  }
5518
5536
  }
5519
5537
 
5520
- class SnapshotCache {
5521
- keys = []
5522
- snapshots = {}
5523
-
5538
+ class SnapshotCache extends LRUCache {
5524
5539
  constructor(size) {
5525
- this.size = size;
5540
+ super(size, toCacheKey);
5526
5541
  }
5527
5542
 
5528
- has(location) {
5529
- return toCacheKey(location) in this.snapshots
5530
- }
5531
-
5532
- get(location) {
5533
- if (this.has(location)) {
5534
- const snapshot = this.read(location);
5535
- this.touch(location);
5536
- return snapshot
5537
- }
5538
- }
5539
-
5540
- put(location, snapshot) {
5541
- this.write(location, snapshot);
5542
- this.touch(location);
5543
- return snapshot
5544
- }
5545
-
5546
- clear() {
5547
- this.snapshots = {};
5548
- }
5549
-
5550
- // Private
5551
-
5552
- read(location) {
5553
- return this.snapshots[toCacheKey(location)]
5554
- }
5555
-
5556
- write(location, snapshot) {
5557
- this.snapshots[toCacheKey(location)] = snapshot;
5558
- }
5559
-
5560
- touch(location) {
5561
- const key = toCacheKey(location);
5562
- const index = this.keys.indexOf(key);
5563
- if (index > -1) this.keys.splice(index, 1);
5564
- this.keys.unshift(key);
5565
- this.trim();
5566
- }
5567
-
5568
- trim() {
5569
- for (const key of this.keys.splice(this.size)) {
5570
- delete this.snapshots[key];
5571
- }
5543
+ get snapshots() {
5544
+ return this.entries
5572
5545
  }
5573
5546
  }
5574
5547
 
@@ -5582,7 +5555,7 @@ Copyright © 2025 37signals LLC
5582
5555
  }
5583
5556
 
5584
5557
  renderPage(snapshot, isPreview = false, willRender = true, visit) {
5585
- const shouldMorphPage = this.isPageRefresh(visit) && this.snapshot.shouldMorphPage;
5558
+ const shouldMorphPage = this.isPageRefresh(visit) && (visit?.refresh?.method || this.snapshot.refreshMethod) === "morph";
5586
5559
  const rendererClass = shouldMorphPage ? MorphingPageRenderer : PageRenderer;
5587
5560
 
5588
5561
  const renderer = new rendererClass(this.snapshot, snapshot, isPreview, willRender);
@@ -5626,7 +5599,7 @@ Copyright © 2025 37signals LLC
5626
5599
  }
5627
5600
 
5628
5601
  shouldPreserveScrollPosition(visit) {
5629
- return this.isPageRefresh(visit) && this.snapshot.shouldPreserveScrollPosition
5602
+ return this.isPageRefresh(visit) && (visit?.refresh?.scroll || this.snapshot.refreshScroll) === "preserve"
5630
5603
  }
5631
5604
 
5632
5605
  get snapshot() {
@@ -5816,11 +5789,14 @@ Copyright © 2025 37signals LLC
5816
5789
  }
5817
5790
  }
5818
5791
 
5819
- refresh(url, requestId) {
5792
+ refresh(url, options = {}) {
5793
+ options = typeof options === "string" ? { requestId: options } : options;
5794
+
5795
+ const { method, requestId, scroll } = options;
5820
5796
  const isRecentRequest = requestId && this.recentRequests.has(requestId);
5821
5797
  const isCurrentUrl = url === document.baseURI;
5822
5798
  if (!isRecentRequest && !this.navigator.currentVisit && isCurrentUrl) {
5823
- this.visit(url, { action: "replace", shouldCacheSnapshot: false });
5799
+ this.visit(url, { action: "replace", shouldCacheSnapshot: false, refresh: { method, scroll } });
5824
5800
  }
5825
5801
  }
5826
5802
 
@@ -5924,6 +5900,12 @@ Copyright © 2025 37signals LLC
5924
5900
  }
5925
5901
  }
5926
5902
 
5903
+ historyPoppedWithEmptyState(location) {
5904
+ this.history.replace(location);
5905
+ this.view.lastRenderedLocation = location;
5906
+ this.view.cacheSnapshot();
5907
+ }
5908
+
5927
5909
  // Scroll observer delegate
5928
5910
 
5929
5911
  scrollPositionChanged(position) {
@@ -5968,7 +5950,7 @@ Copyright © 2025 37signals LLC
5968
5950
  // Navigator delegate
5969
5951
 
5970
5952
  allowsVisitingLocationWithAction(location, action) {
5971
- return this.locationWithActionIsSamePage(location, action) || this.applicationAllowsVisitingLocation(location)
5953
+ return this.applicationAllowsVisitingLocation(location)
5972
5954
  }
5973
5955
 
5974
5956
  visitProposedToLocation(location, options) {
@@ -5984,9 +5966,7 @@ Copyright © 2025 37signals LLC
5984
5966
  this.view.markVisitDirection(visit.direction);
5985
5967
  }
5986
5968
  extendURLWithDeprecatedProperties(visit.location);
5987
- if (!visit.silent) {
5988
- this.notifyApplicationAfterVisitingLocation(visit.location, visit.action);
5989
- }
5969
+ this.notifyApplicationAfterVisitingLocation(visit.location, visit.action);
5990
5970
  }
5991
5971
 
5992
5972
  visitCompleted(visit) {
@@ -5995,14 +5975,6 @@ Copyright © 2025 37signals LLC
5995
5975
  this.notifyApplicationAfterPageLoad(visit.getTimingMetrics());
5996
5976
  }
5997
5977
 
5998
- locationWithActionIsSamePage(location, action) {
5999
- return this.navigator.locationWithActionIsSamePage(location, action)
6000
- }
6001
-
6002
- visitScrolledToSamePageLocation(oldURL, newURL) {
6003
- this.notifyApplicationAfterVisitingSamePageLocation(oldURL, newURL);
6004
- }
6005
-
6006
5978
  // Form submit observer delegate
6007
5979
 
6008
5980
  willSubmitForm(form, submitter) {
@@ -6042,9 +6014,7 @@ Copyright © 2025 37signals LLC
6042
6014
  // Page view delegate
6043
6015
 
6044
6016
  viewWillCacheSnapshot() {
6045
- if (!this.navigator.currentVisit?.silent) {
6046
- this.notifyApplicationBeforeCachingSnapshot();
6047
- }
6017
+ this.notifyApplicationBeforeCachingSnapshot();
6048
6018
  }
6049
6019
 
6050
6020
  allowsImmediateRender({ element }, options) {
@@ -6136,15 +6106,6 @@ Copyright © 2025 37signals LLC
6136
6106
  })
6137
6107
  }
6138
6108
 
6139
- notifyApplicationAfterVisitingSamePageLocation(oldURL, newURL) {
6140
- dispatchEvent(
6141
- new HashChangeEvent("hashchange", {
6142
- oldURL: oldURL.toString(),
6143
- newURL: newURL.toString()
6144
- })
6145
- );
6146
- }
6147
-
6148
6109
  notifyApplicationAfterFrameLoad(frame) {
6149
6110
  return dispatch("turbo:frame-load", { target: frame })
6150
6111
  }
@@ -6230,7 +6191,7 @@ Copyright © 2025 37signals LLC
6230
6191
  };
6231
6192
 
6232
6193
  const session = new Session(recentRequests);
6233
- const { cache, navigator: navigator$1 } = session;
6194
+ const { cache, navigator } = session;
6234
6195
 
6235
6196
  /**
6236
6197
  * Starts the main session.
@@ -6296,19 +6257,6 @@ Copyright © 2025 37signals LLC
6296
6257
  session.renderStreamMessage(message);
6297
6258
  }
6298
6259
 
6299
- /**
6300
- * Removes all entries from the Turbo Drive page cache.
6301
- * Call this when state has changed on the server that may affect cached pages.
6302
- *
6303
- * @deprecated since version 7.2.0 in favor of `Turbo.cache.clear()`
6304
- */
6305
- function clearCache() {
6306
- console.warn(
6307
- "Please replace `Turbo.clearCache()` with `Turbo.cache.clear()`. The top-level function is deprecated and will be removed in a future version of Turbo.`"
6308
- );
6309
- session.clearCache();
6310
- }
6311
-
6312
6260
  /**
6313
6261
  * Sets the delay after which the progress bar will appear during navigation.
6314
6262
  *
@@ -6368,7 +6316,7 @@ Copyright © 2025 37signals LLC
6368
6316
 
6369
6317
  var Turbo = /*#__PURE__*/Object.freeze({
6370
6318
  __proto__: null,
6371
- navigator: navigator$1,
6319
+ navigator: navigator,
6372
6320
  session: session,
6373
6321
  cache: cache,
6374
6322
  PageRenderer: PageRenderer,
@@ -6382,7 +6330,6 @@ Copyright © 2025 37signals LLC
6382
6330
  connectStreamSource: connectStreamSource,
6383
6331
  disconnectStreamSource: disconnectStreamSource,
6384
6332
  renderStreamMessage: renderStreamMessage,
6385
- clearCache: clearCache,
6386
6333
  setProgressBarDelay: setProgressBarDelay,
6387
6334
  setConfirmMethod: setConfirmMethod,
6388
6335
  setFormMode: setFormMode,
@@ -6437,11 +6384,17 @@ Copyright © 2025 37signals LLC
6437
6384
  this.formLinkClickObserver.stop();
6438
6385
  this.linkInterceptor.stop();
6439
6386
  this.formSubmitObserver.stop();
6387
+
6388
+ if (!this.element.hasAttribute("recurse")) {
6389
+ this.#currentFetchRequest?.cancel();
6390
+ }
6440
6391
  }
6441
6392
  }
6442
6393
 
6443
6394
  disabledChanged() {
6444
- if (this.loadingStyle == FrameLoadingStyle.eager) {
6395
+ if (this.disabled) {
6396
+ this.#currentFetchRequest?.cancel();
6397
+ } else if (this.loadingStyle == FrameLoadingStyle.eager) {
6445
6398
  this.#loadSourceURL();
6446
6399
  }
6447
6400
  }
@@ -6449,6 +6402,10 @@ Copyright © 2025 37signals LLC
6449
6402
  sourceURLChanged() {
6450
6403
  if (this.#isIgnoringChangesTo("src")) return
6451
6404
 
6405
+ if (!this.sourceURL) {
6406
+ this.#currentFetchRequest?.cancel();
6407
+ }
6408
+
6452
6409
  if (this.element.isConnected) {
6453
6410
  this.complete = false;
6454
6411
  }
@@ -6550,15 +6507,18 @@ Copyright © 2025 37signals LLC
6550
6507
  }
6551
6508
 
6552
6509
  this.formSubmission = new FormSubmission(this, element, submitter);
6510
+
6553
6511
  const { fetchRequest } = this.formSubmission;
6554
- this.prepareRequest(fetchRequest);
6512
+ const frame = this.#findFrameElement(element, submitter);
6513
+
6514
+ this.prepareRequest(fetchRequest, frame);
6555
6515
  this.formSubmission.start();
6556
6516
  }
6557
6517
 
6558
6518
  // Fetch request delegate
6559
6519
 
6560
- prepareRequest(request) {
6561
- request.headers["Turbo-Frame"] = this.id;
6520
+ prepareRequest(request, frame = this) {
6521
+ request.headers["Turbo-Frame"] = frame.id;
6562
6522
 
6563
6523
  if (this.currentNavigationElement?.hasAttribute("data-turbo-stream")) {
6564
6524
  request.acceptResponseType(StreamMessage.contentType);
@@ -6800,7 +6760,9 @@ Copyright © 2025 37signals LLC
6800
6760
 
6801
6761
  #findFrameElement(element, submitter) {
6802
6762
  const id = getAttribute("data-turbo-frame", submitter, element) || this.element.getAttribute("target");
6803
- return getFrameElementById(id) ?? this.element
6763
+ const target = this.#getFrameElementById(id);
6764
+
6765
+ return target instanceof FrameElement ? target : this.element
6804
6766
  }
6805
6767
 
6806
6768
  async extractForeignFrameElement(container) {
@@ -6844,9 +6806,11 @@ Copyright © 2025 37signals LLC
6844
6806
  }
6845
6807
 
6846
6808
  if (id) {
6847
- const frameElement = getFrameElementById(id);
6809
+ const frameElement = this.#getFrameElementById(id);
6848
6810
  if (frameElement) {
6849
6811
  return !frameElement.disabled
6812
+ } else if (id == "_parent") {
6813
+ return false
6850
6814
  }
6851
6815
  }
6852
6816
 
@@ -6867,8 +6831,12 @@ Copyright © 2025 37signals LLC
6867
6831
  return this.element.id
6868
6832
  }
6869
6833
 
6834
+ get disabled() {
6835
+ return this.element.disabled
6836
+ }
6837
+
6870
6838
  get enabled() {
6871
- return !this.element.disabled
6839
+ return !this.disabled
6872
6840
  }
6873
6841
 
6874
6842
  get sourceURL() {
@@ -6928,13 +6896,15 @@ Copyright © 2025 37signals LLC
6928
6896
  callback();
6929
6897
  delete this.currentNavigationElement;
6930
6898
  }
6931
- }
6932
6899
 
6933
- function getFrameElementById(id) {
6934
- if (id != null) {
6935
- const element = document.getElementById(id);
6936
- if (element instanceof FrameElement) {
6937
- return element
6900
+ #getFrameElementById(id) {
6901
+ if (id != null) {
6902
+ const element = id === "_parent" ?
6903
+ this.element.parentElement.closest("turbo-frame") :
6904
+ document.getElementById(id);
6905
+ if (element instanceof FrameElement) {
6906
+ return element
6907
+ }
6938
6908
  }
6939
6909
  }
6940
6910
  }
@@ -6959,6 +6929,7 @@ Copyright © 2025 37signals LLC
6959
6929
 
6960
6930
  const StreamActions = {
6961
6931
  after() {
6932
+ this.removeDuplicateTargetSiblings();
6962
6933
  this.targetElements.forEach((e) => e.parentElement?.insertBefore(this.templateContent, e.nextSibling));
6963
6934
  },
6964
6935
 
@@ -6968,6 +6939,7 @@ Copyright © 2025 37signals LLC
6968
6939
  },
6969
6940
 
6970
6941
  before() {
6942
+ this.removeDuplicateTargetSiblings();
6971
6943
  this.targetElements.forEach((e) => e.parentElement?.insertBefore(this.templateContent, e));
6972
6944
  },
6973
6945
 
@@ -7006,7 +6978,11 @@ Copyright © 2025 37signals LLC
7006
6978
  },
7007
6979
 
7008
6980
  refresh() {
7009
- session.refresh(this.baseURI, this.requestId);
6981
+ const method = this.getAttribute("method");
6982
+ const requestId = this.requestId;
6983
+ const scroll = this.getAttribute("scroll");
6984
+
6985
+ session.refresh(this.baseURI, { method, requestId, scroll });
7010
6986
  }
7011
6987
  };
7012
6988
 
@@ -7084,6 +7060,23 @@ Copyright © 2025 37signals LLC
7084
7060
  return existingChildren.filter((c) => newChildrenIds.includes(c.getAttribute("id")))
7085
7061
  }
7086
7062
 
7063
+ /**
7064
+ * Removes duplicate siblings (by ID)
7065
+ */
7066
+ removeDuplicateTargetSiblings() {
7067
+ this.duplicateSiblings.forEach((c) => c.remove());
7068
+ }
7069
+
7070
+ /**
7071
+ * Gets the list of duplicate siblings (i.e. those with the same ID)
7072
+ */
7073
+ get duplicateSiblings() {
7074
+ const existingChildren = this.targetElements.flatMap((e) => [...e.parentElement.children]).filter((c) => !!c.id);
7075
+ const newChildrenIds = [...(this.templateContent?.children || [])].filter((c) => !!c.id).map((c) => c.id);
7076
+
7077
+ return existingChildren.filter((c) => newChildrenIds.includes(c.id))
7078
+ }
7079
+
7087
7080
  /**
7088
7081
  * Gets the action function to be performed.
7089
7082
  */
@@ -7235,11 +7228,11 @@ Copyright © 2025 37signals LLC
7235
7228
  }
7236
7229
 
7237
7230
  (() => {
7238
- let element = document.currentScript;
7239
- if (!element) return
7240
- if (element.hasAttribute("data-turbo-suppress-warning")) return
7231
+ const scriptElement = document.currentScript;
7232
+ if (!scriptElement) return
7233
+ if (scriptElement.hasAttribute("data-turbo-suppress-warning")) return
7241
7234
 
7242
- element = element.parentElement;
7235
+ let element = scriptElement.parentElement;
7243
7236
  while (element) {
7244
7237
  if (element == document.body) {
7245
7238
  return console.warn(
@@ -7253,7 +7246,7 @@ Copyright © 2025 37signals LLC
7253
7246
  ——
7254
7247
  Suppress this warning by adding a "data-turbo-suppress-warning" attribute to: %s
7255
7248
  `,
7256
- element.outerHTML
7249
+ scriptElement.outerHTML
7257
7250
  )
7258
7251
  }
7259
7252
 
@@ -7277,7 +7270,6 @@ Copyright © 2025 37signals LLC
7277
7270
  exports.StreamElement = StreamElement;
7278
7271
  exports.StreamSourceElement = StreamSourceElement;
7279
7272
  exports.cache = cache;
7280
- exports.clearCache = clearCache;
7281
7273
  exports.config = config;
7282
7274
  exports.connectStreamSource = connectStreamSource;
7283
7275
  exports.disconnectStreamSource = disconnectStreamSource;
@@ -7289,7 +7281,7 @@ Copyright © 2025 37signals LLC
7289
7281
  exports.morphChildren = morphChildren;
7290
7282
  exports.morphElements = morphElements;
7291
7283
  exports.morphTurboFrameElements = morphTurboFrameElements;
7292
- exports.navigator = navigator$1;
7284
+ exports.navigator = navigator;
7293
7285
  exports.registerAdapter = registerAdapter;
7294
7286
  exports.renderStreamMessage = renderStreamMessage;
7295
7287
  exports.session = session;