UNPKG

203 kBJavaScriptView Raw
1/*!
2Turbo 8.0.12
3Copyright © 2024 37signals LLC
4 */
5(function (global, factory) {
6 typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
7 typeof define === 'function' && define.amd ? define(['exports'], factory) :
8 (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.Turbo = {}));
9})(this, (function (exports) { 'use strict';
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 const FrameLoadingStyle = {
109 eager: "eager",
110 lazy: "lazy"
111 };
112
113 /**
114 * Contains a fragment of HTML which is updated based on navigation within
115 * it (e.g. via links or form submissions).
116 *
117 * @customElement turbo-frame
118 * @example
119 * <turbo-frame id="messages">
120 * <a href="/messages/expanded">
121 * Show all expanded messages in this frame.
122 * </a>
123 *
124 * <form action="/messages">
125 * Show response from this form within this frame.
126 * </form>
127 * </turbo-frame>
128 */
129 class FrameElement extends HTMLElement {
130 static delegateConstructor = undefined
131
132 loaded = Promise.resolve()
133
134 static get observedAttributes() {
135 return ["disabled", "loading", "src"]
136 }
137
138 constructor() {
139 super();
140 this.delegate = new FrameElement.delegateConstructor(this);
141 }
142
143 connectedCallback() {
144 this.delegate.connect();
145 }
146
147 disconnectedCallback() {
148 this.delegate.disconnect();
149 }
150
151 reload() {
152 return this.delegate.sourceURLReloaded()
153 }
154
155 attributeChangedCallback(name) {
156 if (name == "loading") {
157 this.delegate.loadingStyleChanged();
158 } else if (name == "src") {
159 this.delegate.sourceURLChanged();
160 } else if (name == "disabled") {
161 this.delegate.disabledChanged();
162 }
163 }
164
165 /**
166 * Gets the URL to lazily load source HTML from
167 */
168 get src() {
169 return this.getAttribute("src")
170 }
171
172 /**
173 * Sets the URL to lazily load source HTML from
174 */
175 set src(value) {
176 if (value) {
177 this.setAttribute("src", value);
178 } else {
179 this.removeAttribute("src");
180 }
181 }
182
183 /**
184 * Gets the refresh mode for the frame.
185 */
186 get refresh() {
187 return this.getAttribute("refresh")
188 }
189
190 /**
191 * Sets the refresh mode for the frame.
192 */
193 set refresh(value) {
194 if (value) {
195 this.setAttribute("refresh", value);
196 } else {
197 this.removeAttribute("refresh");
198 }
199 }
200
201 get shouldReloadWithMorph() {
202 return this.src && this.refresh === "morph"
203 }
204
205 /**
206 * Determines if the element is loading
207 */
208 get loading() {
209 return frameLoadingStyleFromString(this.getAttribute("loading") || "")
210 }
211
212 /**
213 * Sets the value of if the element is loading
214 */
215 set loading(value) {
216 if (value) {
217 this.setAttribute("loading", value);
218 } else {
219 this.removeAttribute("loading");
220 }
221 }
222
223 /**
224 * Gets the disabled state of the frame.
225 *
226 * If disabled, no requests will be intercepted by the frame.
227 */
228 get disabled() {
229 return this.hasAttribute("disabled")
230 }
231
232 /**
233 * Sets the disabled state of the frame.
234 *
235 * If disabled, no requests will be intercepted by the frame.
236 */
237 set disabled(value) {
238 if (value) {
239 this.setAttribute("disabled", "");
240 } else {
241 this.removeAttribute("disabled");
242 }
243 }
244
245 /**
246 * Gets the autoscroll state of the frame.
247 *
248 * If true, the frame will be scrolled into view automatically on update.
249 */
250 get autoscroll() {
251 return this.hasAttribute("autoscroll")
252 }
253
254 /**
255 * Sets the autoscroll state of the frame.
256 *
257 * If true, the frame will be scrolled into view automatically on update.
258 */
259 set autoscroll(value) {
260 if (value) {
261 this.setAttribute("autoscroll", "");
262 } else {
263 this.removeAttribute("autoscroll");
264 }
265 }
266
267 /**
268 * Determines if the element has finished loading
269 */
270 get complete() {
271 return !this.delegate.isLoading
272 }
273
274 /**
275 * Gets the active state of the frame.
276 *
277 * If inactive, source changes will not be observed.
278 */
279 get isActive() {
280 return this.ownerDocument === document && !this.isPreview
281 }
282
283 /**
284 * Sets the active state of the frame.
285 *
286 * If inactive, source changes will not be observed.
287 */
288 get isPreview() {
289 return this.ownerDocument?.documentElement?.hasAttribute("data-turbo-preview")
290 }
291 }
292
293 function frameLoadingStyleFromString(style) {
294 switch (style.toLowerCase()) {
295 case "lazy":
296 return FrameLoadingStyle.lazy
297 default:
298 return FrameLoadingStyle.eager
299 }
300 }
301
302 const drive = {
303 enabled: true,
304 progressBarDelay: 500,
305 unvisitableExtensions: new Set(
306 [
307 ".7z", ".aac", ".apk", ".avi", ".bmp", ".bz2", ".css", ".csv", ".deb", ".dmg", ".doc",
308 ".docx", ".exe", ".gif", ".gz", ".heic", ".heif", ".ico", ".iso", ".jpeg", ".jpg",
309 ".js", ".json", ".m4a", ".mkv", ".mov", ".mp3", ".mp4", ".mpeg", ".mpg", ".msi",
310 ".ogg", ".ogv", ".pdf", ".pkg", ".png", ".ppt", ".pptx", ".rar", ".rtf",
311 ".svg", ".tar", ".tif", ".tiff", ".txt", ".wav", ".webm", ".webp", ".wma", ".wmv",
312 ".xls", ".xlsx", ".xml", ".zip"
313 ]
314 )
315 };
316
317 function activateScriptElement(element) {
318 if (element.getAttribute("data-turbo-eval") == "false") {
319 return element
320 } else {
321 const createdScriptElement = document.createElement("script");
322 const cspNonce = getCspNonce();
323 if (cspNonce) {
324 createdScriptElement.nonce = cspNonce;
325 }
326 createdScriptElement.textContent = element.textContent;
327 createdScriptElement.async = false;
328 copyElementAttributes(createdScriptElement, element);
329 return createdScriptElement
330 }
331 }
332
333 function copyElementAttributes(destinationElement, sourceElement) {
334 for (const { name, value } of sourceElement.attributes) {
335 destinationElement.setAttribute(name, value);
336 }
337 }
338
339 function createDocumentFragment(html) {
340 const template = document.createElement("template");
341 template.innerHTML = html;
342 return template.content
343 }
344
345 function dispatch(eventName, { target, cancelable, detail } = {}) {
346 const event = new CustomEvent(eventName, {
347 cancelable,
348 bubbles: true,
349 composed: true,
350 detail
351 });
352
353 if (target && target.isConnected) {
354 target.dispatchEvent(event);
355 } else {
356 document.documentElement.dispatchEvent(event);
357 }
358
359 return event
360 }
361
362 function cancelEvent(event) {
363 event.preventDefault();
364 event.stopImmediatePropagation();
365 }
366
367 function nextRepaint() {
368 if (document.visibilityState === "hidden") {
369 return nextEventLoopTick()
370 } else {
371 return nextAnimationFrame()
372 }
373 }
374
375 function nextAnimationFrame() {
376 return new Promise((resolve) => requestAnimationFrame(() => resolve()))
377 }
378
379 function nextEventLoopTick() {
380 return new Promise((resolve) => setTimeout(() => resolve(), 0))
381 }
382
383 function nextMicrotask() {
384 return Promise.resolve()
385 }
386
387 function parseHTMLDocument(html = "") {
388 return new DOMParser().parseFromString(html, "text/html")
389 }
390
391 function unindent(strings, ...values) {
392 const lines = interpolate(strings, values).replace(/^\n/, "").split("\n");
393 const match = lines[0].match(/^\s+/);
394 const indent = match ? match[0].length : 0;
395 return lines.map((line) => line.slice(indent)).join("\n")
396 }
397
398 function interpolate(strings, values) {
399 return strings.reduce((result, string, i) => {
400 const value = values[i] == undefined ? "" : values[i];
401 return result + string + value
402 }, "")
403 }
404
405 function uuid() {
406 return Array.from({ length: 36 })
407 .map((_, i) => {
408 if (i == 8 || i == 13 || i == 18 || i == 23) {
409 return "-"
410 } else if (i == 14) {
411 return "4"
412 } else if (i == 19) {
413 return (Math.floor(Math.random() * 4) + 8).toString(16)
414 } else {
415 return Math.floor(Math.random() * 15).toString(16)
416 }
417 })
418 .join("")
419 }
420
421 function getAttribute(attributeName, ...elements) {
422 for (const value of elements.map((element) => element?.getAttribute(attributeName))) {
423 if (typeof value == "string") return value
424 }
425
426 return null
427 }
428
429 function hasAttribute(attributeName, ...elements) {
430 return elements.some((element) => element && element.hasAttribute(attributeName))
431 }
432
433 function markAsBusy(...elements) {
434 for (const element of elements) {
435 if (element.localName == "turbo-frame") {
436 element.setAttribute("busy", "");
437 }
438 element.setAttribute("aria-busy", "true");
439 }
440 }
441
442 function clearBusyState(...elements) {
443 for (const element of elements) {
444 if (element.localName == "turbo-frame") {
445 element.removeAttribute("busy");
446 }
447
448 element.removeAttribute("aria-busy");
449 }
450 }
451
452 function waitForLoad(element, timeoutInMilliseconds = 2000) {
453 return new Promise((resolve) => {
454 const onComplete = () => {
455 element.removeEventListener("error", onComplete);
456 element.removeEventListener("load", onComplete);
457 resolve();
458 };
459
460 element.addEventListener("load", onComplete, { once: true });
461 element.addEventListener("error", onComplete, { once: true });
462 setTimeout(resolve, timeoutInMilliseconds);
463 })
464 }
465
466 function getHistoryMethodForAction(action) {
467 switch (action) {
468 case "replace":
469 return history.replaceState
470 case "advance":
471 case "restore":
472 return history.pushState
473 }
474 }
475
476 function isAction(action) {
477 return action == "advance" || action == "replace" || action == "restore"
478 }
479
480 function getVisitAction(...elements) {
481 const action = getAttribute("data-turbo-action", ...elements);
482
483 return isAction(action) ? action : null
484 }
485
486 function getMetaElement(name) {
487 return document.querySelector(`meta[name="${name}"]`)
488 }
489
490 function getMetaContent(name) {
491 const element = getMetaElement(name);
492 return element && element.content
493 }
494
495 function getCspNonce() {
496 const element = getMetaElement("csp-nonce");
497
498 if (element) {
499 const { nonce, content } = element;
500 return nonce == "" ? content : nonce
501 }
502 }
503
504 function setMetaContent(name, content) {
505 let element = getMetaElement(name);
506
507 if (!element) {
508 element = document.createElement("meta");
509 element.setAttribute("name", name);
510
511 document.head.appendChild(element);
512 }
513
514 element.setAttribute("content", content);
515
516 return element
517 }
518
519 function findClosestRecursively(element, selector) {
520 if (element instanceof Element) {
521 return (
522 element.closest(selector) || findClosestRecursively(element.assignedSlot || element.getRootNode()?.host, selector)
523 )
524 }
525 }
526
527 function elementIsFocusable(element) {
528 const inertDisabledOrHidden = "[inert], :disabled, [hidden], details:not([open]), dialog:not([open])";
529
530 return !!element && element.closest(inertDisabledOrHidden) == null && typeof element.focus == "function"
531 }
532
533 function queryAutofocusableElement(elementOrDocumentFragment) {
534 return Array.from(elementOrDocumentFragment.querySelectorAll("[autofocus]")).find(elementIsFocusable)
535 }
536
537 async function around(callback, reader) {
538 const before = reader();
539
540 callback();
541
542 await nextAnimationFrame();
543
544 const after = reader();
545
546 return [before, after]
547 }
548
549 function doesNotTargetIFrame(name) {
550 if (name === "_blank") {
551 return false
552 } else if (name) {
553 for (const element of document.getElementsByName(name)) {
554 if (element instanceof HTMLIFrameElement) return false
555 }
556
557 return true
558 } else {
559 return true
560 }
561 }
562
563 function findLinkFromClickTarget(target) {
564 return findClosestRecursively(target, "a[href]:not([target^=_]):not([download])")
565 }
566
567 function getLocationForLink(link) {
568 return expandURL(link.getAttribute("href") || "")
569 }
570
571 function debounce(fn, delay) {
572 let timeoutId = null;
573
574 return (...args) => {
575 const callback = () => fn.apply(this, args);
576 clearTimeout(timeoutId);
577 timeoutId = setTimeout(callback, delay);
578 }
579 }
580
581 const submitter = {
582 "aria-disabled": {
583 beforeSubmit: submitter => {
584 submitter.setAttribute("aria-disabled", "true");
585 submitter.addEventListener("click", cancelEvent);
586 },
587
588 afterSubmit: submitter => {
589 submitter.removeAttribute("aria-disabled");
590 submitter.removeEventListener("click", cancelEvent);
591 }
592 },
593
594 "disabled": {
595 beforeSubmit: submitter => submitter.disabled = true,
596 afterSubmit: submitter => submitter.disabled = false
597 }
598 };
599
600 class Config {
601 #submitter = null
602
603 constructor(config) {
604 Object.assign(this, config);
605 }
606
607 get submitter() {
608 return this.#submitter
609 }
610
611 set submitter(value) {
612 this.#submitter = submitter[value] || value;
613 }
614 }
615
616 const forms = new Config({
617 mode: "on",
618 submitter: "disabled"
619 });
620
621 const config = {
622 drive,
623 forms
624 };
625
626 function expandURL(locatable) {
627 return new URL(locatable.toString(), document.baseURI)
628 }
629
630 function getAnchor(url) {
631 let anchorMatch;
632 if (url.hash) {
633 return url.hash.slice(1)
634 // eslint-disable-next-line no-cond-assign
635 } else if ((anchorMatch = url.href.match(/#(.*)$/))) {
636 return anchorMatch[1]
637 }
638 }
639
640 function getAction$1(form, submitter) {
641 const action = submitter?.getAttribute("formaction") || form.getAttribute("action") || form.action;
642
643 return expandURL(action)
644 }
645
646 function getExtension(url) {
647 return (getLastPathComponent(url).match(/\.[^.]*$/) || [])[0] || ""
648 }
649
650 function isPrefixedBy(baseURL, url) {
651 const prefix = getPrefix(url);
652 return baseURL.href === expandURL(prefix).href || baseURL.href.startsWith(prefix)
653 }
654
655 function locationIsVisitable(location, rootLocation) {
656 return isPrefixedBy(location, rootLocation) && !config.drive.unvisitableExtensions.has(getExtension(location))
657 }
658
659 function getRequestURL(url) {
660 const anchor = getAnchor(url);
661 return anchor != null ? url.href.slice(0, -(anchor.length + 1)) : url.href
662 }
663
664 function toCacheKey(url) {
665 return getRequestURL(url)
666 }
667
668 function urlsAreEqual(left, right) {
669 return expandURL(left).href == expandURL(right).href
670 }
671
672 function getPathComponents(url) {
673 return url.pathname.split("/").slice(1)
674 }
675
676 function getLastPathComponent(url) {
677 return getPathComponents(url).slice(-1)[0]
678 }
679
680 function getPrefix(url) {
681 return addTrailingSlash(url.origin + url.pathname)
682 }
683
684 function addTrailingSlash(value) {
685 return value.endsWith("/") ? value : value + "/"
686 }
687
688 class FetchResponse {
689 constructor(response) {
690 this.response = response;
691 }
692
693 get succeeded() {
694 return this.response.ok
695 }
696
697 get failed() {
698 return !this.succeeded
699 }
700
701 get clientError() {
702 return this.statusCode >= 400 && this.statusCode <= 499
703 }
704
705 get serverError() {
706 return this.statusCode >= 500 && this.statusCode <= 599
707 }
708
709 get redirected() {
710 return this.response.redirected
711 }
712
713 get location() {
714 return expandURL(this.response.url)
715 }
716
717 get isHTML() {
718 return this.contentType && this.contentType.match(/^(?:text\/([^\s;,]+\b)?html|application\/xhtml\+xml)\b/)
719 }
720
721 get statusCode() {
722 return this.response.status
723 }
724
725 get contentType() {
726 return this.header("Content-Type")
727 }
728
729 get responseText() {
730 return this.response.clone().text()
731 }
732
733 get responseHTML() {
734 if (this.isHTML) {
735 return this.response.clone().text()
736 } else {
737 return Promise.resolve(undefined)
738 }
739 }
740
741 header(name) {
742 return this.response.headers.get(name)
743 }
744 }
745
746 class LimitedSet extends Set {
747 constructor(maxSize) {
748 super();
749 this.maxSize = maxSize;
750 }
751
752 add(value) {
753 if (this.size >= this.maxSize) {
754 const iterator = this.values();
755 const oldestValue = iterator.next().value;
756 this.delete(oldestValue);
757 }
758 super.add(value);
759 }
760 }
761
762 const recentRequests = new LimitedSet(20);
763
764 const nativeFetch = window.fetch;
765
766 function fetchWithTurboHeaders(url, options = {}) {
767 const modifiedHeaders = new Headers(options.headers || {});
768 const requestUID = uuid();
769 recentRequests.add(requestUID);
770 modifiedHeaders.append("X-Turbo-Request-Id", requestUID);
771
772 return nativeFetch(url, {
773 ...options,
774 headers: modifiedHeaders
775 })
776 }
777
778 function fetchMethodFromString(method) {
779 switch (method.toLowerCase()) {
780 case "get":
781 return FetchMethod.get
782 case "post":
783 return FetchMethod.post
784 case "put":
785 return FetchMethod.put
786 case "patch":
787 return FetchMethod.patch
788 case "delete":
789 return FetchMethod.delete
790 }
791 }
792
793 const FetchMethod = {
794 get: "get",
795 post: "post",
796 put: "put",
797 patch: "patch",
798 delete: "delete"
799 };
800
801 function fetchEnctypeFromString(encoding) {
802 switch (encoding.toLowerCase()) {
803 case FetchEnctype.multipart:
804 return FetchEnctype.multipart
805 case FetchEnctype.plain:
806 return FetchEnctype.plain
807 default:
808 return FetchEnctype.urlEncoded
809 }
810 }
811
812 const FetchEnctype = {
813 urlEncoded: "application/x-www-form-urlencoded",
814 multipart: "multipart/form-data",
815 plain: "text/plain"
816 };
817
818 class FetchRequest {
819 abortController = new AbortController()
820 #resolveRequestPromise = (_value) => {}
821
822 constructor(delegate, method, location, requestBody = new URLSearchParams(), target = null, enctype = FetchEnctype.urlEncoded) {
823 const [url, body] = buildResourceAndBody(expandURL(location), method, requestBody, enctype);
824
825 this.delegate = delegate;
826 this.url = url;
827 this.target = target;
828 this.fetchOptions = {
829 credentials: "same-origin",
830 redirect: "follow",
831 method: method.toUpperCase(),
832 headers: { ...this.defaultHeaders },
833 body: body,
834 signal: this.abortSignal,
835 referrer: this.delegate.referrer?.href
836 };
837 this.enctype = enctype;
838 }
839
840 get method() {
841 return this.fetchOptions.method
842 }
843
844 set method(value) {
845 const fetchBody = this.isSafe ? this.url.searchParams : this.fetchOptions.body || new FormData();
846 const fetchMethod = fetchMethodFromString(value) || FetchMethod.get;
847
848 this.url.search = "";
849
850 const [url, body] = buildResourceAndBody(this.url, fetchMethod, fetchBody, this.enctype);
851
852 this.url = url;
853 this.fetchOptions.body = body;
854 this.fetchOptions.method = fetchMethod.toUpperCase();
855 }
856
857 get headers() {
858 return this.fetchOptions.headers
859 }
860
861 set headers(value) {
862 this.fetchOptions.headers = value;
863 }
864
865 get body() {
866 if (this.isSafe) {
867 return this.url.searchParams
868 } else {
869 return this.fetchOptions.body
870 }
871 }
872
873 set body(value) {
874 this.fetchOptions.body = value;
875 }
876
877 get location() {
878 return this.url
879 }
880
881 get params() {
882 return this.url.searchParams
883 }
884
885 get entries() {
886 return this.body ? Array.from(this.body.entries()) : []
887 }
888
889 cancel() {
890 this.abortController.abort();
891 }
892
893 async perform() {
894 const { fetchOptions } = this;
895 this.delegate.prepareRequest(this);
896 const event = await this.#allowRequestToBeIntercepted(fetchOptions);
897 try {
898 this.delegate.requestStarted(this);
899
900 if (event.detail.fetchRequest) {
901 this.response = event.detail.fetchRequest.response;
902 } else {
903 this.response = fetchWithTurboHeaders(this.url.href, fetchOptions);
904 }
905
906 const response = await this.response;
907 return await this.receive(response)
908 } catch (error) {
909 if (error.name !== "AbortError") {
910 if (this.#willDelegateErrorHandling(error)) {
911 this.delegate.requestErrored(this, error);
912 }
913 throw error
914 }
915 } finally {
916 this.delegate.requestFinished(this);
917 }
918 }
919
920 async receive(response) {
921 const fetchResponse = new FetchResponse(response);
922 const event = dispatch("turbo:before-fetch-response", {
923 cancelable: true,
924 detail: { fetchResponse },
925 target: this.target
926 });
927 if (event.defaultPrevented) {
928 this.delegate.requestPreventedHandlingResponse(this, fetchResponse);
929 } else if (fetchResponse.succeeded) {
930 this.delegate.requestSucceededWithResponse(this, fetchResponse);
931 } else {
932 this.delegate.requestFailedWithResponse(this, fetchResponse);
933 }
934 return fetchResponse
935 }
936
937 get defaultHeaders() {
938 return {
939 Accept: "text/html, application/xhtml+xml"
940 }
941 }
942
943 get isSafe() {
944 return isSafe(this.method)
945 }
946
947 get abortSignal() {
948 return this.abortController.signal
949 }
950
951 acceptResponseType(mimeType) {
952 this.headers["Accept"] = [mimeType, this.headers["Accept"]].join(", ");
953 }
954
955 async #allowRequestToBeIntercepted(fetchOptions) {
956 const requestInterception = new Promise((resolve) => (this.#resolveRequestPromise = resolve));
957 const event = dispatch("turbo:before-fetch-request", {
958 cancelable: true,
959 detail: {
960 fetchOptions,
961 url: this.url,
962 resume: this.#resolveRequestPromise
963 },
964 target: this.target
965 });
966 this.url = event.detail.url;
967 if (event.defaultPrevented) await requestInterception;
968
969 return event
970 }
971
972 #willDelegateErrorHandling(error) {
973 const event = dispatch("turbo:fetch-request-error", {
974 target: this.target,
975 cancelable: true,
976 detail: { request: this, error: error }
977 });
978
979 return !event.defaultPrevented
980 }
981 }
982
983 function isSafe(fetchMethod) {
984 return fetchMethodFromString(fetchMethod) == FetchMethod.get
985 }
986
987 function buildResourceAndBody(resource, method, requestBody, enctype) {
988 const searchParams =
989 Array.from(requestBody).length > 0 ? new URLSearchParams(entriesExcludingFiles(requestBody)) : resource.searchParams;
990
991 if (isSafe(method)) {
992 return [mergeIntoURLSearchParams(resource, searchParams), null]
993 } else if (enctype == FetchEnctype.urlEncoded) {
994 return [resource, searchParams]
995 } else {
996 return [resource, requestBody]
997 }
998 }
999
1000 function entriesExcludingFiles(requestBody) {
1001 const entries = [];
1002
1003 for (const [name, value] of requestBody) {
1004 if (value instanceof File) continue
1005 else entries.push([name, value]);
1006 }
1007
1008 return entries
1009 }
1010
1011 function mergeIntoURLSearchParams(url, requestBody) {
1012 const searchParams = new URLSearchParams(entriesExcludingFiles(requestBody));
1013
1014 url.search = searchParams.toString();
1015
1016 return url
1017 }
1018
1019 class AppearanceObserver {
1020 started = false
1021
1022 constructor(delegate, element) {
1023 this.delegate = delegate;
1024 this.element = element;
1025 this.intersectionObserver = new IntersectionObserver(this.intersect);
1026 }
1027
1028 start() {
1029 if (!this.started) {
1030 this.started = true;
1031 this.intersectionObserver.observe(this.element);
1032 }
1033 }
1034
1035 stop() {
1036 if (this.started) {
1037 this.started = false;
1038 this.intersectionObserver.unobserve(this.element);
1039 }
1040 }
1041
1042 intersect = (entries) => {
1043 const lastEntry = entries.slice(-1)[0];
1044 if (lastEntry?.isIntersecting) {
1045 this.delegate.elementAppearedInViewport(this.element);
1046 }
1047 }
1048 }
1049
1050 class StreamMessage {
1051 static contentType = "text/vnd.turbo-stream.html"
1052
1053 static wrap(message) {
1054 if (typeof message == "string") {
1055 return new this(createDocumentFragment(message))
1056 } else {
1057 return message
1058 }
1059 }
1060
1061 constructor(fragment) {
1062 this.fragment = importStreamElements(fragment);
1063 }
1064 }
1065
1066 function importStreamElements(fragment) {
1067 for (const element of fragment.querySelectorAll("turbo-stream")) {
1068 const streamElement = document.importNode(element, true);
1069
1070 for (const inertScriptElement of streamElement.templateElement.content.querySelectorAll("script")) {
1071 inertScriptElement.replaceWith(activateScriptElement(inertScriptElement));
1072 }
1073
1074 element.replaceWith(streamElement);
1075 }
1076
1077 return fragment
1078 }
1079
1080 const PREFETCH_DELAY = 100;
1081
1082 class PrefetchCache {
1083 #prefetchTimeout = null
1084 #prefetched = null
1085
1086 get(url) {
1087 if (this.#prefetched && this.#prefetched.url === url && this.#prefetched.expire > Date.now()) {
1088 return this.#prefetched.request
1089 }
1090 }
1091
1092 setLater(url, request, ttl) {
1093 this.clear();
1094
1095 this.#prefetchTimeout = setTimeout(() => {
1096 request.perform();
1097 this.set(url, request, ttl);
1098 this.#prefetchTimeout = null;
1099 }, PREFETCH_DELAY);
1100 }
1101
1102 set(url, request, ttl) {
1103 this.#prefetched = { url, request, expire: new Date(new Date().getTime() + ttl) };
1104 }
1105
1106 clear() {
1107 if (this.#prefetchTimeout) clearTimeout(this.#prefetchTimeout);
1108 this.#prefetched = null;
1109 }
1110 }
1111
1112 const cacheTtl = 10 * 1000;
1113 const prefetchCache = new PrefetchCache();
1114
1115 const FormSubmissionState = {
1116 initialized: "initialized",
1117 requesting: "requesting",
1118 waiting: "waiting",
1119 receiving: "receiving",
1120 stopping: "stopping",
1121 stopped: "stopped"
1122 };
1123
1124 class FormSubmission {
1125 state = FormSubmissionState.initialized
1126
1127 static confirmMethod(message) {
1128 return Promise.resolve(confirm(message))
1129 }
1130
1131 constructor(delegate, formElement, submitter, mustRedirect = false) {
1132 const method = getMethod(formElement, submitter);
1133 const action = getAction(getFormAction(formElement, submitter), method);
1134 const body = buildFormData(formElement, submitter);
1135 const enctype = getEnctype(formElement, submitter);
1136
1137 this.delegate = delegate;
1138 this.formElement = formElement;
1139 this.submitter = submitter;
1140 this.fetchRequest = new FetchRequest(this, method, action, body, formElement, enctype);
1141 this.mustRedirect = mustRedirect;
1142 }
1143
1144 get method() {
1145 return this.fetchRequest.method
1146 }
1147
1148 set method(value) {
1149 this.fetchRequest.method = value;
1150 }
1151
1152 get action() {
1153 return this.fetchRequest.url.toString()
1154 }
1155
1156 set action(value) {
1157 this.fetchRequest.url = expandURL(value);
1158 }
1159
1160 get body() {
1161 return this.fetchRequest.body
1162 }
1163
1164 get enctype() {
1165 return this.fetchRequest.enctype
1166 }
1167
1168 get isSafe() {
1169 return this.fetchRequest.isSafe
1170 }
1171
1172 get location() {
1173 return this.fetchRequest.url
1174 }
1175
1176 // The submission process
1177
1178 async start() {
1179 const { initialized, requesting } = FormSubmissionState;
1180 const confirmationMessage = getAttribute("data-turbo-confirm", this.submitter, this.formElement);
1181
1182 if (typeof confirmationMessage === "string") {
1183 const confirmMethod = typeof config.forms.confirm === "function" ?
1184 config.forms.confirm :
1185 FormSubmission.confirmMethod;
1186
1187 const answer = await confirmMethod(confirmationMessage, this.formElement, this.submitter);
1188 if (!answer) {
1189 return
1190 }
1191 }
1192
1193 if (this.state == initialized) {
1194 this.state = requesting;
1195 return this.fetchRequest.perform()
1196 }
1197 }
1198
1199 stop() {
1200 const { stopping, stopped } = FormSubmissionState;
1201 if (this.state != stopping && this.state != stopped) {
1202 this.state = stopping;
1203 this.fetchRequest.cancel();
1204 return true
1205 }
1206 }
1207
1208 // Fetch request delegate
1209
1210 prepareRequest(request) {
1211 if (!request.isSafe) {
1212 const token = getCookieValue(getMetaContent("csrf-param")) || getMetaContent("csrf-token");
1213 if (token) {
1214 request.headers["X-CSRF-Token"] = token;
1215 }
1216 }
1217
1218 if (this.requestAcceptsTurboStreamResponse(request)) {
1219 request.acceptResponseType(StreamMessage.contentType);
1220 }
1221 }
1222
1223 requestStarted(_request) {
1224 this.state = FormSubmissionState.waiting;
1225 if (this.submitter) config.forms.submitter.beforeSubmit(this.submitter);
1226 this.setSubmitsWith();
1227 markAsBusy(this.formElement);
1228 dispatch("turbo:submit-start", {
1229 target: this.formElement,
1230 detail: { formSubmission: this }
1231 });
1232 this.delegate.formSubmissionStarted(this);
1233 }
1234
1235 requestPreventedHandlingResponse(request, response) {
1236 prefetchCache.clear();
1237
1238 this.result = { success: response.succeeded, fetchResponse: response };
1239 }
1240
1241 requestSucceededWithResponse(request, response) {
1242 if (response.clientError || response.serverError) {
1243 this.delegate.formSubmissionFailedWithResponse(this, response);
1244 return
1245 }
1246
1247 prefetchCache.clear();
1248
1249 if (this.requestMustRedirect(request) && responseSucceededWithoutRedirect(response)) {
1250 const error = new Error("Form responses must redirect to another location");
1251 this.delegate.formSubmissionErrored(this, error);
1252 } else {
1253 this.state = FormSubmissionState.receiving;
1254 this.result = { success: true, fetchResponse: response };
1255 this.delegate.formSubmissionSucceededWithResponse(this, response);
1256 }
1257 }
1258
1259 requestFailedWithResponse(request, response) {
1260 this.result = { success: false, fetchResponse: response };
1261 this.delegate.formSubmissionFailedWithResponse(this, response);
1262 }
1263
1264 requestErrored(request, error) {
1265 this.result = { success: false, error };
1266 this.delegate.formSubmissionErrored(this, error);
1267 }
1268
1269 requestFinished(_request) {
1270 this.state = FormSubmissionState.stopped;
1271 if (this.submitter) config.forms.submitter.afterSubmit(this.submitter);
1272 this.resetSubmitterText();
1273 clearBusyState(this.formElement);
1274 dispatch("turbo:submit-end", {
1275 target: this.formElement,
1276 detail: { formSubmission: this, ...this.result }
1277 });
1278 this.delegate.formSubmissionFinished(this);
1279 }
1280
1281 // Private
1282
1283 setSubmitsWith() {
1284 if (!this.submitter || !this.submitsWith) return
1285
1286 if (this.submitter.matches("button")) {
1287 this.originalSubmitText = this.submitter.innerHTML;
1288 this.submitter.innerHTML = this.submitsWith;
1289 } else if (this.submitter.matches("input")) {
1290 const input = this.submitter;
1291 this.originalSubmitText = input.value;
1292 input.value = this.submitsWith;
1293 }
1294 }
1295
1296 resetSubmitterText() {
1297 if (!this.submitter || !this.originalSubmitText) return
1298
1299 if (this.submitter.matches("button")) {
1300 this.submitter.innerHTML = this.originalSubmitText;
1301 } else if (this.submitter.matches("input")) {
1302 const input = this.submitter;
1303 input.value = this.originalSubmitText;
1304 }
1305 }
1306
1307 requestMustRedirect(request) {
1308 return !request.isSafe && this.mustRedirect
1309 }
1310
1311 requestAcceptsTurboStreamResponse(request) {
1312 return !request.isSafe || hasAttribute("data-turbo-stream", this.submitter, this.formElement)
1313 }
1314
1315 get submitsWith() {
1316 return this.submitter?.getAttribute("data-turbo-submits-with")
1317 }
1318 }
1319
1320 function buildFormData(formElement, submitter) {
1321 const formData = new FormData(formElement);
1322 const name = submitter?.getAttribute("name");
1323 const value = submitter?.getAttribute("value");
1324
1325 if (name) {
1326 formData.append(name, value || "");
1327 }
1328
1329 return formData
1330 }
1331
1332 function getCookieValue(cookieName) {
1333 if (cookieName != null) {
1334 const cookies = document.cookie ? document.cookie.split("; ") : [];
1335 const cookie = cookies.find((cookie) => cookie.startsWith(cookieName));
1336 if (cookie) {
1337 const value = cookie.split("=").slice(1).join("=");
1338 return value ? decodeURIComponent(value) : undefined
1339 }
1340 }
1341 }
1342
1343 function responseSucceededWithoutRedirect(response) {
1344 return response.statusCode == 200 && !response.redirected
1345 }
1346
1347 function getFormAction(formElement, submitter) {
1348 const formElementAction = typeof formElement.action === "string" ? formElement.action : null;
1349
1350 if (submitter?.hasAttribute("formaction")) {
1351 return submitter.getAttribute("formaction") || ""
1352 } else {
1353 return formElement.getAttribute("action") || formElementAction || ""
1354 }
1355 }
1356
1357 function getAction(formAction, fetchMethod) {
1358 const action = expandURL(formAction);
1359
1360 if (isSafe(fetchMethod)) {
1361 action.search = "";
1362 }
1363
1364 return action
1365 }
1366
1367 function getMethod(formElement, submitter) {
1368 const method = submitter?.getAttribute("formmethod") || formElement.getAttribute("method") || "";
1369 return fetchMethodFromString(method.toLowerCase()) || FetchMethod.get
1370 }
1371
1372 function getEnctype(formElement, submitter) {
1373 return fetchEnctypeFromString(submitter?.getAttribute("formenctype") || formElement.enctype)
1374 }
1375
1376 class Snapshot {
1377 constructor(element) {
1378 this.element = element;
1379 }
1380
1381 get activeElement() {
1382 return this.element.ownerDocument.activeElement
1383 }
1384
1385 get children() {
1386 return [...this.element.children]
1387 }
1388
1389 hasAnchor(anchor) {
1390 return this.getElementForAnchor(anchor) != null
1391 }
1392
1393 getElementForAnchor(anchor) {
1394 return anchor ? this.element.querySelector(`[id='${anchor}'], a[name='${anchor}']`) : null
1395 }
1396
1397 get isConnected() {
1398 return this.element.isConnected
1399 }
1400
1401 get firstAutofocusableElement() {
1402 return queryAutofocusableElement(this.element)
1403 }
1404
1405 get permanentElements() {
1406 return queryPermanentElementsAll(this.element)
1407 }
1408
1409 getPermanentElementById(id) {
1410 return getPermanentElementById(this.element, id)
1411 }
1412
1413 getPermanentElementMapForSnapshot(snapshot) {
1414 const permanentElementMap = {};
1415
1416 for (const currentPermanentElement of this.permanentElements) {
1417 const { id } = currentPermanentElement;
1418 const newPermanentElement = snapshot.getPermanentElementById(id);
1419 if (newPermanentElement) {
1420 permanentElementMap[id] = [currentPermanentElement, newPermanentElement];
1421 }
1422 }
1423
1424 return permanentElementMap
1425 }
1426 }
1427
1428 function getPermanentElementById(node, id) {
1429 return node.querySelector(`#${id}[data-turbo-permanent]`)
1430 }
1431
1432 function queryPermanentElementsAll(node) {
1433 return node.querySelectorAll("[id][data-turbo-permanent]")
1434 }
1435
1436 class FormSubmitObserver {
1437 started = false
1438
1439 constructor(delegate, eventTarget) {
1440 this.delegate = delegate;
1441 this.eventTarget = eventTarget;
1442 }
1443
1444 start() {
1445 if (!this.started) {
1446 this.eventTarget.addEventListener("submit", this.submitCaptured, true);
1447 this.started = true;
1448 }
1449 }
1450
1451 stop() {
1452 if (this.started) {
1453 this.eventTarget.removeEventListener("submit", this.submitCaptured, true);
1454 this.started = false;
1455 }
1456 }
1457
1458 submitCaptured = () => {
1459 this.eventTarget.removeEventListener("submit", this.submitBubbled, false);
1460 this.eventTarget.addEventListener("submit", this.submitBubbled, false);
1461 }
1462
1463 submitBubbled = (event) => {
1464 if (!event.defaultPrevented) {
1465 const form = event.target instanceof HTMLFormElement ? event.target : undefined;
1466 const submitter = event.submitter || undefined;
1467
1468 if (
1469 form &&
1470 submissionDoesNotDismissDialog(form, submitter) &&
1471 submissionDoesNotTargetIFrame(form, submitter) &&
1472 this.delegate.willSubmitForm(form, submitter)
1473 ) {
1474 event.preventDefault();
1475 event.stopImmediatePropagation();
1476 this.delegate.formSubmitted(form, submitter);
1477 }
1478 }
1479 }
1480 }
1481
1482 function submissionDoesNotDismissDialog(form, submitter) {
1483 const method = submitter?.getAttribute("formmethod") || form.getAttribute("method");
1484
1485 return method != "dialog"
1486 }
1487
1488 function submissionDoesNotTargetIFrame(form, submitter) {
1489 const target = submitter?.getAttribute("formtarget") || form.getAttribute("target");
1490
1491 return doesNotTargetIFrame(target)
1492 }
1493
1494 class View {
1495 #resolveRenderPromise = (_value) => {}
1496 #resolveInterceptionPromise = (_value) => {}
1497
1498 constructor(delegate, element) {
1499 this.delegate = delegate;
1500 this.element = element;
1501 }
1502
1503 // Scrolling
1504
1505 scrollToAnchor(anchor) {
1506 const element = this.snapshot.getElementForAnchor(anchor);
1507 if (element) {
1508 this.scrollToElement(element);
1509 this.focusElement(element);
1510 } else {
1511 this.scrollToPosition({ x: 0, y: 0 });
1512 }
1513 }
1514
1515 scrollToAnchorFromLocation(location) {
1516 this.scrollToAnchor(getAnchor(location));
1517 }
1518
1519 scrollToElement(element) {
1520 element.scrollIntoView();
1521 }
1522
1523 focusElement(element) {
1524 if (element instanceof HTMLElement) {
1525 if (element.hasAttribute("tabindex")) {
1526 element.focus();
1527 } else {
1528 element.setAttribute("tabindex", "-1");
1529 element.focus();
1530 element.removeAttribute("tabindex");
1531 }
1532 }
1533 }
1534
1535 scrollToPosition({ x, y }) {
1536 this.scrollRoot.scrollTo(x, y);
1537 }
1538
1539 scrollToTop() {
1540 this.scrollToPosition({ x: 0, y: 0 });
1541 }
1542
1543 get scrollRoot() {
1544 return window
1545 }
1546
1547 // Rendering
1548
1549 async render(renderer) {
1550 const { isPreview, shouldRender, willRender, newSnapshot: snapshot } = renderer;
1551
1552 // A workaround to ignore tracked element mismatch reloads when performing
1553 // a promoted Visit from a frame navigation
1554 const shouldInvalidate = willRender;
1555
1556 if (shouldRender) {
1557 try {
1558 this.renderPromise = new Promise((resolve) => (this.#resolveRenderPromise = resolve));
1559 this.renderer = renderer;
1560 await this.prepareToRenderSnapshot(renderer);
1561
1562 const renderInterception = new Promise((resolve) => (this.#resolveInterceptionPromise = resolve));
1563 const options = { resume: this.#resolveInterceptionPromise, render: this.renderer.renderElement, renderMethod: this.renderer.renderMethod };
1564 const immediateRender = this.delegate.allowsImmediateRender(snapshot, options);
1565 if (!immediateRender) await renderInterception;
1566
1567 await this.renderSnapshot(renderer);
1568 this.delegate.viewRenderedSnapshot(snapshot, isPreview, this.renderer.renderMethod);
1569 this.delegate.preloadOnLoadLinksForView(this.element);
1570 this.finishRenderingSnapshot(renderer);
1571 } finally {
1572 delete this.renderer;
1573 this.#resolveRenderPromise(undefined);
1574 delete this.renderPromise;
1575 }
1576 } else if (shouldInvalidate) {
1577 this.invalidate(renderer.reloadReason);
1578 }
1579 }
1580
1581 invalidate(reason) {
1582 this.delegate.viewInvalidated(reason);
1583 }
1584
1585 async prepareToRenderSnapshot(renderer) {
1586 this.markAsPreview(renderer.isPreview);
1587 await renderer.prepareToRender();
1588 }
1589
1590 markAsPreview(isPreview) {
1591 if (isPreview) {
1592 this.element.setAttribute("data-turbo-preview", "");
1593 } else {
1594 this.element.removeAttribute("data-turbo-preview");
1595 }
1596 }
1597
1598 markVisitDirection(direction) {
1599 this.element.setAttribute("data-turbo-visit-direction", direction);
1600 }
1601
1602 unmarkVisitDirection() {
1603 this.element.removeAttribute("data-turbo-visit-direction");
1604 }
1605
1606 async renderSnapshot(renderer) {
1607 await renderer.render();
1608 }
1609
1610 finishRenderingSnapshot(renderer) {
1611 renderer.finishRendering();
1612 }
1613 }
1614
1615 class FrameView extends View {
1616 missing() {
1617 this.element.innerHTML = `<strong class="turbo-frame-error">Content missing</strong>`;
1618 }
1619
1620 get snapshot() {
1621 return new Snapshot(this.element)
1622 }
1623 }
1624
1625 class LinkInterceptor {
1626 constructor(delegate, element) {
1627 this.delegate = delegate;
1628 this.element = element;
1629 }
1630
1631 start() {
1632 this.element.addEventListener("click", this.clickBubbled);
1633 document.addEventListener("turbo:click", this.linkClicked);
1634 document.addEventListener("turbo:before-visit", this.willVisit);
1635 }
1636
1637 stop() {
1638 this.element.removeEventListener("click", this.clickBubbled);
1639 document.removeEventListener("turbo:click", this.linkClicked);
1640 document.removeEventListener("turbo:before-visit", this.willVisit);
1641 }
1642
1643 clickBubbled = (event) => {
1644 if (this.clickEventIsSignificant(event)) {
1645 this.clickEvent = event;
1646 } else {
1647 delete this.clickEvent;
1648 }
1649 }
1650
1651 linkClicked = (event) => {
1652 if (this.clickEvent && this.clickEventIsSignificant(event)) {
1653 if (this.delegate.shouldInterceptLinkClick(event.target, event.detail.url, event.detail.originalEvent)) {
1654 this.clickEvent.preventDefault();
1655 event.preventDefault();
1656 this.delegate.linkClickIntercepted(event.target, event.detail.url, event.detail.originalEvent);
1657 }
1658 }
1659 delete this.clickEvent;
1660 }
1661
1662 willVisit = (_event) => {
1663 delete this.clickEvent;
1664 }
1665
1666 clickEventIsSignificant(event) {
1667 const target = event.composed ? event.target?.parentElement : event.target;
1668 const element = findLinkFromClickTarget(target) || target;
1669
1670 return element instanceof Element && element.closest("turbo-frame, html") == this.element
1671 }
1672 }
1673
1674 class LinkClickObserver {
1675 started = false
1676
1677 constructor(delegate, eventTarget) {
1678 this.delegate = delegate;
1679 this.eventTarget = eventTarget;
1680 }
1681
1682 start() {
1683 if (!this.started) {
1684 this.eventTarget.addEventListener("click", this.clickCaptured, true);
1685 this.started = true;
1686 }
1687 }
1688
1689 stop() {
1690 if (this.started) {
1691 this.eventTarget.removeEventListener("click", this.clickCaptured, true);
1692 this.started = false;
1693 }
1694 }
1695
1696 clickCaptured = () => {
1697 this.eventTarget.removeEventListener("click", this.clickBubbled, false);
1698 this.eventTarget.addEventListener("click", this.clickBubbled, false);
1699 }
1700
1701 clickBubbled = (event) => {
1702 if (event instanceof MouseEvent && this.clickEventIsSignificant(event)) {
1703 const target = (event.composedPath && event.composedPath()[0]) || event.target;
1704 const link = findLinkFromClickTarget(target);
1705 if (link && doesNotTargetIFrame(link.target)) {
1706 const location = getLocationForLink(link);
1707 if (this.delegate.willFollowLinkToLocation(link, location, event)) {
1708 event.preventDefault();
1709 this.delegate.followedLinkToLocation(link, location);
1710 }
1711 }
1712 }
1713 }
1714
1715 clickEventIsSignificant(event) {
1716 return !(
1717 (event.target && event.target.isContentEditable) ||
1718 event.defaultPrevented ||
1719 event.which > 1 ||
1720 event.altKey ||
1721 event.ctrlKey ||
1722 event.metaKey ||
1723 event.shiftKey
1724 )
1725 }
1726 }
1727
1728 class FormLinkClickObserver {
1729 constructor(delegate, element) {
1730 this.delegate = delegate;
1731 this.linkInterceptor = new LinkClickObserver(this, element);
1732 }
1733
1734 start() {
1735 this.linkInterceptor.start();
1736 }
1737
1738 stop() {
1739 this.linkInterceptor.stop();
1740 }
1741
1742 // Link hover observer delegate
1743
1744 canPrefetchRequestToLocation(link, location) {
1745 return false
1746 }
1747
1748 prefetchAndCacheRequestToLocation(link, location) {
1749 return
1750 }
1751
1752 // Link click observer delegate
1753
1754 willFollowLinkToLocation(link, location, originalEvent) {
1755 return (
1756 this.delegate.willSubmitFormLinkToLocation(link, location, originalEvent) &&
1757 (link.hasAttribute("data-turbo-method") || link.hasAttribute("data-turbo-stream"))
1758 )
1759 }
1760
1761 followedLinkToLocation(link, location) {
1762 const form = document.createElement("form");
1763
1764 const type = "hidden";
1765 for (const [name, value] of location.searchParams) {
1766 form.append(Object.assign(document.createElement("input"), { type, name, value }));
1767 }
1768
1769 const action = Object.assign(location, { search: "" });
1770 form.setAttribute("data-turbo", "true");
1771 form.setAttribute("action", action.href);
1772 form.setAttribute("hidden", "");
1773
1774 const method = link.getAttribute("data-turbo-method");
1775 if (method) form.setAttribute("method", method);
1776
1777 const turboFrame = link.getAttribute("data-turbo-frame");
1778 if (turboFrame) form.setAttribute("data-turbo-frame", turboFrame);
1779
1780 const turboAction = getVisitAction(link);
1781 if (turboAction) form.setAttribute("data-turbo-action", turboAction);
1782
1783 const turboConfirm = link.getAttribute("data-turbo-confirm");
1784 if (turboConfirm) form.setAttribute("data-turbo-confirm", turboConfirm);
1785
1786 const turboStream = link.hasAttribute("data-turbo-stream");
1787 if (turboStream) form.setAttribute("data-turbo-stream", "");
1788
1789 this.delegate.submittedFormLinkToLocation(link, location, form);
1790
1791 document.body.appendChild(form);
1792 form.addEventListener("turbo:submit-end", () => form.remove(), { once: true });
1793 requestAnimationFrame(() => form.requestSubmit());
1794 }
1795 }
1796
1797 class Bardo {
1798 static async preservingPermanentElements(delegate, permanentElementMap, callback) {
1799 const bardo = new this(delegate, permanentElementMap);
1800 bardo.enter();
1801 await callback();
1802 bardo.leave();
1803 }
1804
1805 constructor(delegate, permanentElementMap) {
1806 this.delegate = delegate;
1807 this.permanentElementMap = permanentElementMap;
1808 }
1809
1810 enter() {
1811 for (const id in this.permanentElementMap) {
1812 const [currentPermanentElement, newPermanentElement] = this.permanentElementMap[id];
1813 this.delegate.enteringBardo(currentPermanentElement, newPermanentElement);
1814 this.replaceNewPermanentElementWithPlaceholder(newPermanentElement);
1815 }
1816 }
1817
1818 leave() {
1819 for (const id in this.permanentElementMap) {
1820 const [currentPermanentElement] = this.permanentElementMap[id];
1821 this.replaceCurrentPermanentElementWithClone(currentPermanentElement);
1822 this.replacePlaceholderWithPermanentElement(currentPermanentElement);
1823 this.delegate.leavingBardo(currentPermanentElement);
1824 }
1825 }
1826
1827 replaceNewPermanentElementWithPlaceholder(permanentElement) {
1828 const placeholder = createPlaceholderForPermanentElement(permanentElement);
1829 permanentElement.replaceWith(placeholder);
1830 }
1831
1832 replaceCurrentPermanentElementWithClone(permanentElement) {
1833 const clone = permanentElement.cloneNode(true);
1834 permanentElement.replaceWith(clone);
1835 }
1836
1837 replacePlaceholderWithPermanentElement(permanentElement) {
1838 const placeholder = this.getPlaceholderById(permanentElement.id);
1839 placeholder?.replaceWith(permanentElement);
1840 }
1841
1842 getPlaceholderById(id) {
1843 return this.placeholders.find((element) => element.content == id)
1844 }
1845
1846 get placeholders() {
1847 return [...document.querySelectorAll("meta[name=turbo-permanent-placeholder][content]")]
1848 }
1849 }
1850
1851 function createPlaceholderForPermanentElement(permanentElement) {
1852 const element = document.createElement("meta");
1853 element.setAttribute("name", "turbo-permanent-placeholder");
1854 element.setAttribute("content", permanentElement.id);
1855 return element
1856 }
1857
1858 class Renderer {
1859 #activeElement = null
1860
1861 static renderElement(currentElement, newElement) {
1862 // Abstract method
1863 }
1864
1865 constructor(currentSnapshot, newSnapshot, isPreview, willRender = true) {
1866 this.currentSnapshot = currentSnapshot;
1867 this.newSnapshot = newSnapshot;
1868 this.isPreview = isPreview;
1869 this.willRender = willRender;
1870 this.renderElement = this.constructor.renderElement;
1871 this.promise = new Promise((resolve, reject) => (this.resolvingFunctions = { resolve, reject }));
1872 }
1873
1874 get shouldRender() {
1875 return true
1876 }
1877
1878 get shouldAutofocus() {
1879 return true
1880 }
1881
1882 get reloadReason() {
1883 return
1884 }
1885
1886 prepareToRender() {
1887 return
1888 }
1889
1890 render() {
1891 // Abstract method
1892 }
1893
1894 finishRendering() {
1895 if (this.resolvingFunctions) {
1896 this.resolvingFunctions.resolve();
1897 delete this.resolvingFunctions;
1898 }
1899 }
1900
1901 async preservingPermanentElements(callback) {
1902 await Bardo.preservingPermanentElements(this, this.permanentElementMap, callback);
1903 }
1904
1905 focusFirstAutofocusableElement() {
1906 if (this.shouldAutofocus) {
1907 const element = this.connectedSnapshot.firstAutofocusableElement;
1908 if (element) {
1909 element.focus();
1910 }
1911 }
1912 }
1913
1914 // Bardo delegate
1915
1916 enteringBardo(currentPermanentElement) {
1917 if (this.#activeElement) return
1918
1919 if (currentPermanentElement.contains(this.currentSnapshot.activeElement)) {
1920 this.#activeElement = this.currentSnapshot.activeElement;
1921 }
1922 }
1923
1924 leavingBardo(currentPermanentElement) {
1925 if (currentPermanentElement.contains(this.#activeElement) && this.#activeElement instanceof HTMLElement) {
1926 this.#activeElement.focus();
1927
1928 this.#activeElement = null;
1929 }
1930 }
1931
1932 get connectedSnapshot() {
1933 return this.newSnapshot.isConnected ? this.newSnapshot : this.currentSnapshot
1934 }
1935
1936 get currentElement() {
1937 return this.currentSnapshot.element
1938 }
1939
1940 get newElement() {
1941 return this.newSnapshot.element
1942 }
1943
1944 get permanentElementMap() {
1945 return this.currentSnapshot.getPermanentElementMapForSnapshot(this.newSnapshot)
1946 }
1947
1948 get renderMethod() {
1949 return "replace"
1950 }
1951 }
1952
1953 class FrameRenderer extends Renderer {
1954 static renderElement(currentElement, newElement) {
1955 const destinationRange = document.createRange();
1956 destinationRange.selectNodeContents(currentElement);
1957 destinationRange.deleteContents();
1958
1959 const frameElement = newElement;
1960 const sourceRange = frameElement.ownerDocument?.createRange();
1961 if (sourceRange) {
1962 sourceRange.selectNodeContents(frameElement);
1963 currentElement.appendChild(sourceRange.extractContents());
1964 }
1965 }
1966
1967 constructor(delegate, currentSnapshot, newSnapshot, renderElement, isPreview, willRender = true) {
1968 super(currentSnapshot, newSnapshot, renderElement, isPreview, willRender);
1969 this.delegate = delegate;
1970 }
1971
1972 get shouldRender() {
1973 return true
1974 }
1975
1976 async render() {
1977 await nextRepaint();
1978 this.preservingPermanentElements(() => {
1979 this.loadFrameElement();
1980 });
1981 this.scrollFrameIntoView();
1982 await nextRepaint();
1983 this.focusFirstAutofocusableElement();
1984 await nextRepaint();
1985 this.activateScriptElements();
1986 }
1987
1988 loadFrameElement() {
1989 this.delegate.willRenderFrame(this.currentElement, this.newElement);
1990 this.renderElement(this.currentElement, this.newElement);
1991 }
1992
1993 scrollFrameIntoView() {
1994 if (this.currentElement.autoscroll || this.newElement.autoscroll) {
1995 const element = this.currentElement.firstElementChild;
1996 const block = readScrollLogicalPosition(this.currentElement.getAttribute("data-autoscroll-block"), "end");
1997 const behavior = readScrollBehavior(this.currentElement.getAttribute("data-autoscroll-behavior"), "auto");
1998
1999 if (element) {
2000 element.scrollIntoView({ block, behavior });
2001 return true
2002 }
2003 }
2004 return false
2005 }
2006
2007 activateScriptElements() {
2008 for (const inertScriptElement of this.newScriptElements) {
2009 const activatedScriptElement = activateScriptElement(inertScriptElement);
2010 inertScriptElement.replaceWith(activatedScriptElement);
2011 }
2012 }
2013
2014 get newScriptElements() {
2015 return this.currentElement.querySelectorAll("script")
2016 }
2017 }
2018
2019 function readScrollLogicalPosition(value, defaultValue) {
2020 if (value == "end" || value == "start" || value == "center" || value == "nearest") {
2021 return value
2022 } else {
2023 return defaultValue
2024 }
2025 }
2026
2027 function readScrollBehavior(value, defaultValue) {
2028 if (value == "auto" || value == "smooth") {
2029 return value
2030 } else {
2031 return defaultValue
2032 }
2033 }
2034
2035 // base IIFE to define idiomorph
2036 var Idiomorph = (function () {
2037
2038 //=============================================================================
2039 // AND NOW IT BEGINS...
2040 //=============================================================================
2041 let EMPTY_SET = new Set();
2042
2043 // default configuration values, updatable by users now
2044 let defaults = {
2045 morphStyle: "outerHTML",
2046 callbacks : {
2047 beforeNodeAdded: noOp,
2048 afterNodeAdded: noOp,
2049 beforeNodeMorphed: noOp,
2050 afterNodeMorphed: noOp,
2051 beforeNodeRemoved: noOp,
2052 afterNodeRemoved: noOp,
2053 beforeAttributeUpdated: noOp,
2054
2055 },
2056 head: {
2057 style: 'merge',
2058 shouldPreserve: function (elt) {
2059 return elt.getAttribute("im-preserve") === "true";
2060 },
2061 shouldReAppend: function (elt) {
2062 return elt.getAttribute("im-re-append") === "true";
2063 },
2064 shouldRemove: noOp,
2065 afterHeadMorphed: noOp,
2066 }
2067 };
2068
2069 //=============================================================================
2070 // Core Morphing Algorithm - morph, morphNormalizedContent, morphOldNodeTo, morphChildren
2071 //=============================================================================
2072 function morph(oldNode, newContent, config = {}) {
2073
2074 if (oldNode instanceof Document) {
2075 oldNode = oldNode.documentElement;
2076 }
2077
2078 if (typeof newContent === 'string') {
2079 newContent = parseContent(newContent);
2080 }
2081
2082 let normalizedContent = normalizeContent(newContent);
2083
2084 let ctx = createMorphContext(oldNode, normalizedContent, config);
2085
2086 return morphNormalizedContent(oldNode, normalizedContent, ctx);
2087 }
2088
2089 function morphNormalizedContent(oldNode, normalizedNewContent, ctx) {
2090 if (ctx.head.block) {
2091 let oldHead = oldNode.querySelector('head');
2092 let newHead = normalizedNewContent.querySelector('head');
2093 if (oldHead && newHead) {
2094 let promises = handleHeadElement(newHead, oldHead, ctx);
2095 // when head promises resolve, call morph again, ignoring the head tag
2096 Promise.all(promises).then(function () {
2097 morphNormalizedContent(oldNode, normalizedNewContent, Object.assign(ctx, {
2098 head: {
2099 block: false,
2100 ignore: true
2101 }
2102 }));
2103 });
2104 return;
2105 }
2106 }
2107
2108 if (ctx.morphStyle === "innerHTML") {
2109
2110 // innerHTML, so we are only updating the children
2111 morphChildren(normalizedNewContent, oldNode, ctx);
2112 return oldNode.children;
2113
2114 } else if (ctx.morphStyle === "outerHTML" || ctx.morphStyle == null) {
2115 // otherwise find the best element match in the new content, morph that, and merge its siblings
2116 // into either side of the best match
2117 let bestMatch = findBestNodeMatch(normalizedNewContent, oldNode, ctx);
2118
2119 // stash the siblings that will need to be inserted on either side of the best match
2120 let previousSibling = bestMatch?.previousSibling;
2121 let nextSibling = bestMatch?.nextSibling;
2122
2123 // morph it
2124 let morphedNode = morphOldNodeTo(oldNode, bestMatch, ctx);
2125
2126 if (bestMatch) {
2127 // if there was a best match, merge the siblings in too and return the
2128 // whole bunch
2129 return insertSiblings(previousSibling, morphedNode, nextSibling);
2130 } else {
2131 // otherwise nothing was added to the DOM
2132 return []
2133 }
2134 } else {
2135 throw "Do not understand how to morph style " + ctx.morphStyle;
2136 }
2137 }
2138
2139
2140 /**
2141 * @param possibleActiveElement
2142 * @param ctx
2143 * @returns {boolean}
2144 */
2145 function ignoreValueOfActiveElement(possibleActiveElement, ctx) {
2146 return ctx.ignoreActiveValue && possibleActiveElement === document.activeElement && possibleActiveElement !== document.body;
2147 }
2148
2149 /**
2150 * @param oldNode root node to merge content into
2151 * @param newContent new content to merge
2152 * @param ctx the merge context
2153 * @returns {Element} the element that ended up in the DOM
2154 */
2155 function morphOldNodeTo(oldNode, newContent, ctx) {
2156 if (ctx.ignoreActive && oldNode === document.activeElement) ; else if (newContent == null) {
2157 if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode;
2158
2159 oldNode.remove();
2160 ctx.callbacks.afterNodeRemoved(oldNode);
2161 return null;
2162 } else if (!isSoftMatch(oldNode, newContent)) {
2163 if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode;
2164 if (ctx.callbacks.beforeNodeAdded(newContent) === false) return oldNode;
2165
2166 oldNode.parentElement.replaceChild(newContent, oldNode);
2167 ctx.callbacks.afterNodeAdded(newContent);
2168 ctx.callbacks.afterNodeRemoved(oldNode);
2169 return newContent;
2170 } else {
2171 if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) return oldNode;
2172
2173 if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) ; else if (oldNode instanceof HTMLHeadElement && ctx.head.style !== "morph") {
2174 handleHeadElement(newContent, oldNode, ctx);
2175 } else {
2176 syncNodeFrom(newContent, oldNode, ctx);
2177 if (!ignoreValueOfActiveElement(oldNode, ctx)) {
2178 morphChildren(newContent, oldNode, ctx);
2179 }
2180 }
2181 ctx.callbacks.afterNodeMorphed(oldNode, newContent);
2182 return oldNode;
2183 }
2184 }
2185
2186 /**
2187 * This is the core algorithm for matching up children. The idea is to use id sets to try to match up
2188 * nodes as faithfully as possible. We greedily match, which allows us to keep the algorithm fast, but
2189 * by using id sets, we are able to better match up with content deeper in the DOM.
2190 *
2191 * Basic algorithm is, for each node in the new content:
2192 *
2193 * - if we have reached the end of the old parent, append the new content
2194 * - if the new content has an id set match with the current insertion point, morph
2195 * - search for an id set match
2196 * - if id set match found, morph
2197 * - otherwise search for a "soft" match
2198 * - if a soft match is found, morph
2199 * - otherwise, prepend the new node before the current insertion point
2200 *
2201 * The two search algorithms terminate if competing node matches appear to outweigh what can be achieved
2202 * with the current node. See findIdSetMatch() and findSoftMatch() for details.
2203 *
2204 * @param {Element} newParent the parent element of the new content
2205 * @param {Element } oldParent the old content that we are merging the new content into
2206 * @param ctx the merge context
2207 */
2208 function morphChildren(newParent, oldParent, ctx) {
2209
2210 let nextNewChild = newParent.firstChild;
2211 let insertionPoint = oldParent.firstChild;
2212 let newChild;
2213
2214 // run through all the new content
2215 while (nextNewChild) {
2216
2217 newChild = nextNewChild;
2218 nextNewChild = newChild.nextSibling;
2219
2220 // if we are at the end of the exiting parent's children, just append
2221 if (insertionPoint == null) {
2222 if (ctx.callbacks.beforeNodeAdded(newChild) === false) return;
2223
2224 oldParent.appendChild(newChild);
2225 ctx.callbacks.afterNodeAdded(newChild);
2226 removeIdsFromConsideration(ctx, newChild);
2227 continue;
2228 }
2229
2230 // if the current node has an id set match then morph
2231 if (isIdSetMatch(newChild, insertionPoint, ctx)) {
2232 morphOldNodeTo(insertionPoint, newChild, ctx);
2233 insertionPoint = insertionPoint.nextSibling;
2234 removeIdsFromConsideration(ctx, newChild);
2235 continue;
2236 }
2237
2238 // otherwise search forward in the existing old children for an id set match
2239 let idSetMatch = findIdSetMatch(newParent, oldParent, newChild, insertionPoint, ctx);
2240
2241 // if we found a potential match, remove the nodes until that point and morph
2242 if (idSetMatch) {
2243 insertionPoint = removeNodesBetween(insertionPoint, idSetMatch, ctx);
2244 morphOldNodeTo(idSetMatch, newChild, ctx);
2245 removeIdsFromConsideration(ctx, newChild);
2246 continue;
2247 }
2248
2249 // no id set match found, so scan forward for a soft match for the current node
2250 let softMatch = findSoftMatch(newParent, oldParent, newChild, insertionPoint, ctx);
2251
2252 // if we found a soft match for the current node, morph
2253 if (softMatch) {
2254 insertionPoint = removeNodesBetween(insertionPoint, softMatch, ctx);
2255 morphOldNodeTo(softMatch, newChild, ctx);
2256 removeIdsFromConsideration(ctx, newChild);
2257 continue;
2258 }
2259
2260 // abandon all hope of morphing, just insert the new child before the insertion point
2261 // and move on
2262 if (ctx.callbacks.beforeNodeAdded(newChild) === false) return;
2263
2264 oldParent.insertBefore(newChild, insertionPoint);
2265 ctx.callbacks.afterNodeAdded(newChild);
2266 removeIdsFromConsideration(ctx, newChild);
2267 }
2268
2269 // remove any remaining old nodes that didn't match up with new content
2270 while (insertionPoint !== null) {
2271
2272 let tempNode = insertionPoint;
2273 insertionPoint = insertionPoint.nextSibling;
2274 removeNode(tempNode, ctx);
2275 }
2276 }
2277
2278 //=============================================================================
2279 // Attribute Syncing Code
2280 //=============================================================================
2281
2282 /**
2283 * @param attr {String} the attribute to be mutated
2284 * @param to {Element} the element that is going to be updated
2285 * @param updateType {("update"|"remove")}
2286 * @param ctx the merge context
2287 * @returns {boolean} true if the attribute should be ignored, false otherwise
2288 */
2289 function ignoreAttribute(attr, to, updateType, ctx) {
2290 if(attr === 'value' && ctx.ignoreActiveValue && to === document.activeElement){
2291 return true;
2292 }
2293 return ctx.callbacks.beforeAttributeUpdated(attr, to, updateType) === false;
2294 }
2295
2296 /**
2297 * syncs a given node with another node, copying over all attributes and
2298 * inner element state from the 'from' node to the 'to' node
2299 *
2300 * @param {Element} from the element to copy attributes & state from
2301 * @param {Element} to the element to copy attributes & state to
2302 * @param ctx the merge context
2303 */
2304 function syncNodeFrom(from, to, ctx) {
2305 let type = from.nodeType;
2306
2307 // if is an element type, sync the attributes from the
2308 // new node into the new node
2309 if (type === 1 /* element type */) {
2310 const fromAttributes = from.attributes;
2311 const toAttributes = to.attributes;
2312 for (const fromAttribute of fromAttributes) {
2313 if (ignoreAttribute(fromAttribute.name, to, 'update', ctx)) {
2314 continue;
2315 }
2316 if (to.getAttribute(fromAttribute.name) !== fromAttribute.value) {
2317 to.setAttribute(fromAttribute.name, fromAttribute.value);
2318 }
2319 }
2320 // iterate backwards to avoid skipping over items when a delete occurs
2321 for (let i = toAttributes.length - 1; 0 <= i; i--) {
2322 const toAttribute = toAttributes[i];
2323 if (ignoreAttribute(toAttribute.name, to, 'remove', ctx)) {
2324 continue;
2325 }
2326 if (!from.hasAttribute(toAttribute.name)) {
2327 to.removeAttribute(toAttribute.name);
2328 }
2329 }
2330 }
2331
2332 // sync text nodes
2333 if (type === 8 /* comment */ || type === 3 /* text */) {
2334 if (to.nodeValue !== from.nodeValue) {
2335 to.nodeValue = from.nodeValue;
2336 }
2337 }
2338
2339 if (!ignoreValueOfActiveElement(to, ctx)) {
2340 // sync input values
2341 syncInputValue(from, to, ctx);
2342 }
2343 }
2344
2345 /**
2346 * @param from {Element} element to sync the value from
2347 * @param to {Element} element to sync the value to
2348 * @param attributeName {String} the attribute name
2349 * @param ctx the merge context
2350 */
2351 function syncBooleanAttribute(from, to, attributeName, ctx) {
2352 if (from[attributeName] !== to[attributeName]) {
2353 let ignoreUpdate = ignoreAttribute(attributeName, to, 'update', ctx);
2354 if (!ignoreUpdate) {
2355 to[attributeName] = from[attributeName];
2356 }
2357 if (from[attributeName]) {
2358 if (!ignoreUpdate) {
2359 to.setAttribute(attributeName, from[attributeName]);
2360 }
2361 } else {
2362 if (!ignoreAttribute(attributeName, to, 'remove', ctx)) {
2363 to.removeAttribute(attributeName);
2364 }
2365 }
2366 }
2367 }
2368
2369 /**
2370 * NB: many bothans died to bring us information:
2371 *
2372 * https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js
2373 * https://github.com/choojs/nanomorph/blob/master/lib/morph.jsL113
2374 *
2375 * @param from {Element} the element to sync the input value from
2376 * @param to {Element} the element to sync the input value to
2377 * @param ctx the merge context
2378 */
2379 function syncInputValue(from, to, ctx) {
2380 if (from instanceof HTMLInputElement &&
2381 to instanceof HTMLInputElement &&
2382 from.type !== 'file') {
2383
2384 let fromValue = from.value;
2385 let toValue = to.value;
2386
2387 // sync boolean attributes
2388 syncBooleanAttribute(from, to, 'checked', ctx);
2389 syncBooleanAttribute(from, to, 'disabled', ctx);
2390
2391 if (!from.hasAttribute('value')) {
2392 if (!ignoreAttribute('value', to, 'remove', ctx)) {
2393 to.value = '';
2394 to.removeAttribute('value');
2395 }
2396 } else if (fromValue !== toValue) {
2397 if (!ignoreAttribute('value', to, 'update', ctx)) {
2398 to.setAttribute('value', fromValue);
2399 to.value = fromValue;
2400 }
2401 }
2402 } else if (from instanceof HTMLOptionElement) {
2403 syncBooleanAttribute(from, to, 'selected', ctx);
2404 } else if (from instanceof HTMLTextAreaElement && to instanceof HTMLTextAreaElement) {
2405 let fromValue = from.value;
2406 let toValue = to.value;
2407 if (ignoreAttribute('value', to, 'update', ctx)) {
2408 return;
2409 }
2410 if (fromValue !== toValue) {
2411 to.value = fromValue;
2412 }
2413 if (to.firstChild && to.firstChild.nodeValue !== fromValue) {
2414 to.firstChild.nodeValue = fromValue;
2415 }
2416 }
2417 }
2418
2419 //=============================================================================
2420 // the HEAD tag can be handled specially, either w/ a 'merge' or 'append' style
2421 //=============================================================================
2422 function handleHeadElement(newHeadTag, currentHead, ctx) {
2423
2424 let added = [];
2425 let removed = [];
2426 let preserved = [];
2427 let nodesToAppend = [];
2428
2429 let headMergeStyle = ctx.head.style;
2430
2431 // put all new head elements into a Map, by their outerHTML
2432 let srcToNewHeadNodes = new Map();
2433 for (const newHeadChild of newHeadTag.children) {
2434 srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild);
2435 }
2436
2437 // for each elt in the current head
2438 for (const currentHeadElt of currentHead.children) {
2439
2440 // If the current head element is in the map
2441 let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML);
2442 let isReAppended = ctx.head.shouldReAppend(currentHeadElt);
2443 let isPreserved = ctx.head.shouldPreserve(currentHeadElt);
2444 if (inNewContent || isPreserved) {
2445 if (isReAppended) {
2446 // remove the current version and let the new version replace it and re-execute
2447 removed.push(currentHeadElt);
2448 } else {
2449 // this element already exists and should not be re-appended, so remove it from
2450 // the new content map, preserving it in the DOM
2451 srcToNewHeadNodes.delete(currentHeadElt.outerHTML);
2452 preserved.push(currentHeadElt);
2453 }
2454 } else {
2455 if (headMergeStyle === "append") {
2456 // we are appending and this existing element is not new content
2457 // so if and only if it is marked for re-append do we do anything
2458 if (isReAppended) {
2459 removed.push(currentHeadElt);
2460 nodesToAppend.push(currentHeadElt);
2461 }
2462 } else {
2463 // if this is a merge, we remove this content since it is not in the new head
2464 if (ctx.head.shouldRemove(currentHeadElt) !== false) {
2465 removed.push(currentHeadElt);
2466 }
2467 }
2468 }
2469 }
2470
2471 // Push the remaining new head elements in the Map into the
2472 // nodes to append to the head tag
2473 nodesToAppend.push(...srcToNewHeadNodes.values());
2474
2475 let promises = [];
2476 for (const newNode of nodesToAppend) {
2477 let newElt = document.createRange().createContextualFragment(newNode.outerHTML).firstChild;
2478 if (ctx.callbacks.beforeNodeAdded(newElt) !== false) {
2479 if (newElt.href || newElt.src) {
2480 let resolve = null;
2481 let promise = new Promise(function (_resolve) {
2482 resolve = _resolve;
2483 });
2484 newElt.addEventListener('load', function () {
2485 resolve();
2486 });
2487 promises.push(promise);
2488 }
2489 currentHead.appendChild(newElt);
2490 ctx.callbacks.afterNodeAdded(newElt);
2491 added.push(newElt);
2492 }
2493 }
2494
2495 // remove all removed elements, after we have appended the new elements to avoid
2496 // additional network requests for things like style sheets
2497 for (const removedElement of removed) {
2498 if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) {
2499 currentHead.removeChild(removedElement);
2500 ctx.callbacks.afterNodeRemoved(removedElement);
2501 }
2502 }
2503
2504 ctx.head.afterHeadMorphed(currentHead, {added: added, kept: preserved, removed: removed});
2505 return promises;
2506 }
2507
2508 function noOp() {
2509 }
2510
2511 /*
2512 Deep merges the config object and the Idiomoroph.defaults object to
2513 produce a final configuration object
2514 */
2515 function mergeDefaults(config) {
2516 let finalConfig = {};
2517 // copy top level stuff into final config
2518 Object.assign(finalConfig, defaults);
2519 Object.assign(finalConfig, config);
2520
2521 // copy callbacks into final config (do this to deep merge the callbacks)
2522 finalConfig.callbacks = {};
2523 Object.assign(finalConfig.callbacks, defaults.callbacks);
2524 Object.assign(finalConfig.callbacks, config.callbacks);
2525
2526 // copy head config into final config (do this to deep merge the head)
2527 finalConfig.head = {};
2528 Object.assign(finalConfig.head, defaults.head);
2529 Object.assign(finalConfig.head, config.head);
2530 return finalConfig;
2531 }
2532
2533 function createMorphContext(oldNode, newContent, config) {
2534 config = mergeDefaults(config);
2535 return {
2536 target: oldNode,
2537 newContent: newContent,
2538 config: config,
2539 morphStyle: config.morphStyle,
2540 ignoreActive: config.ignoreActive,
2541 ignoreActiveValue: config.ignoreActiveValue,
2542 idMap: createIdMap(oldNode, newContent),
2543 deadIds: new Set(),
2544 callbacks: config.callbacks,
2545 head: config.head
2546 }
2547 }
2548
2549 function isIdSetMatch(node1, node2, ctx) {
2550 if (node1 == null || node2 == null) {
2551 return false;
2552 }
2553 if (node1.nodeType === node2.nodeType && node1.tagName === node2.tagName) {
2554 if (node1.id !== "" && node1.id === node2.id) {
2555 return true;
2556 } else {
2557 return getIdIntersectionCount(ctx, node1, node2) > 0;
2558 }
2559 }
2560 return false;
2561 }
2562
2563 function isSoftMatch(node1, node2) {
2564 if (node1 == null || node2 == null) {
2565 return false;
2566 }
2567 return node1.nodeType === node2.nodeType && node1.tagName === node2.tagName
2568 }
2569
2570 function removeNodesBetween(startInclusive, endExclusive, ctx) {
2571 while (startInclusive !== endExclusive) {
2572 let tempNode = startInclusive;
2573 startInclusive = startInclusive.nextSibling;
2574 removeNode(tempNode, ctx);
2575 }
2576 removeIdsFromConsideration(ctx, endExclusive);
2577 return endExclusive.nextSibling;
2578 }
2579
2580 //=============================================================================
2581 // Scans forward from the insertionPoint in the old parent looking for a potential id match
2582 // for the newChild. We stop if we find a potential id match for the new child OR
2583 // if the number of potential id matches we are discarding is greater than the
2584 // potential id matches for the new child
2585 //=============================================================================
2586 function findIdSetMatch(newContent, oldParent, newChild, insertionPoint, ctx) {
2587
2588 // max id matches we are willing to discard in our search
2589 let newChildPotentialIdCount = getIdIntersectionCount(ctx, newChild, oldParent);
2590
2591 let potentialMatch = null;
2592
2593 // only search forward if there is a possibility of an id match
2594 if (newChildPotentialIdCount > 0) {
2595 let potentialMatch = insertionPoint;
2596 // if there is a possibility of an id match, scan forward
2597 // keep track of the potential id match count we are discarding (the
2598 // newChildPotentialIdCount must be greater than this to make it likely
2599 // worth it)
2600 let otherMatchCount = 0;
2601 while (potentialMatch != null) {
2602
2603 // If we have an id match, return the current potential match
2604 if (isIdSetMatch(newChild, potentialMatch, ctx)) {
2605 return potentialMatch;
2606 }
2607
2608 // computer the other potential matches of this new content
2609 otherMatchCount += getIdIntersectionCount(ctx, potentialMatch, newContent);
2610 if (otherMatchCount > newChildPotentialIdCount) {
2611 // if we have more potential id matches in _other_ content, we
2612 // do not have a good candidate for an id match, so return null
2613 return null;
2614 }
2615
2616 // advanced to the next old content child
2617 potentialMatch = potentialMatch.nextSibling;
2618 }
2619 }
2620 return potentialMatch;
2621 }
2622
2623 //=============================================================================
2624 // Scans forward from the insertionPoint in the old parent looking for a potential soft match
2625 // for the newChild. We stop if we find a potential soft match for the new child OR
2626 // if we find a potential id match in the old parents children OR if we find two
2627 // potential soft matches for the next two pieces of new content
2628 //=============================================================================
2629 function findSoftMatch(newContent, oldParent, newChild, insertionPoint, ctx) {
2630
2631 let potentialSoftMatch = insertionPoint;
2632 let nextSibling = newChild.nextSibling;
2633 let siblingSoftMatchCount = 0;
2634
2635 while (potentialSoftMatch != null) {
2636
2637 if (getIdIntersectionCount(ctx, potentialSoftMatch, newContent) > 0) {
2638 // the current potential soft match has a potential id set match with the remaining new
2639 // content so bail out of looking
2640 return null;
2641 }
2642
2643 // if we have a soft match with the current node, return it
2644 if (isSoftMatch(newChild, potentialSoftMatch)) {
2645 return potentialSoftMatch;
2646 }
2647
2648 if (isSoftMatch(nextSibling, potentialSoftMatch)) {
2649 // the next new node has a soft match with this node, so
2650 // increment the count of future soft matches
2651 siblingSoftMatchCount++;
2652 nextSibling = nextSibling.nextSibling;
2653
2654 // If there are two future soft matches, bail to allow the siblings to soft match
2655 // so that we don't consume future soft matches for the sake of the current node
2656 if (siblingSoftMatchCount >= 2) {
2657 return null;
2658 }
2659 }
2660
2661 // advanced to the next old content child
2662 potentialSoftMatch = potentialSoftMatch.nextSibling;
2663 }
2664
2665 return potentialSoftMatch;
2666 }
2667
2668 function parseContent(newContent) {
2669 let parser = new DOMParser();
2670
2671 // remove svgs to avoid false-positive matches on head, etc.
2672 let contentWithSvgsRemoved = newContent.replace(/<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim, '');
2673
2674 // if the newContent contains a html, head or body tag, we can simply parse it w/o wrapping
2675 if (contentWithSvgsRemoved.match(/<\/html>/) || contentWithSvgsRemoved.match(/<\/head>/) || contentWithSvgsRemoved.match(/<\/body>/)) {
2676 let content = parser.parseFromString(newContent, "text/html");
2677 // if it is a full HTML document, return the document itself as the parent container
2678 if (contentWithSvgsRemoved.match(/<\/html>/)) {
2679 content.generatedByIdiomorph = true;
2680 return content;
2681 } else {
2682 // otherwise return the html element as the parent container
2683 let htmlElement = content.firstChild;
2684 if (htmlElement) {
2685 htmlElement.generatedByIdiomorph = true;
2686 return htmlElement;
2687 } else {
2688 return null;
2689 }
2690 }
2691 } else {
2692 // if it is partial HTML, wrap it in a template tag to provide a parent element and also to help
2693 // deal with touchy tags like tr, tbody, etc.
2694 let responseDoc = parser.parseFromString("<body><template>" + newContent + "</template></body>", "text/html");
2695 let content = responseDoc.body.querySelector('template').content;
2696 content.generatedByIdiomorph = true;
2697 return content
2698 }
2699 }
2700
2701 function normalizeContent(newContent) {
2702 if (newContent == null) {
2703 // noinspection UnnecessaryLocalVariableJS
2704 const dummyParent = document.createElement('div');
2705 return dummyParent;
2706 } else if (newContent.generatedByIdiomorph) {
2707 // the template tag created by idiomorph parsing can serve as a dummy parent
2708 return newContent;
2709 } else if (newContent instanceof Node) {
2710 // a single node is added as a child to a dummy parent
2711 const dummyParent = document.createElement('div');
2712 dummyParent.append(newContent);
2713 return dummyParent;
2714 } else {
2715 // all nodes in the array or HTMLElement collection are consolidated under
2716 // a single dummy parent element
2717 const dummyParent = document.createElement('div');
2718 for (const elt of [...newContent]) {
2719 dummyParent.append(elt);
2720 }
2721 return dummyParent;
2722 }
2723 }
2724
2725 function insertSiblings(previousSibling, morphedNode, nextSibling) {
2726 let stack = [];
2727 let added = [];
2728 while (previousSibling != null) {
2729 stack.push(previousSibling);
2730 previousSibling = previousSibling.previousSibling;
2731 }
2732 while (stack.length > 0) {
2733 let node = stack.pop();
2734 added.push(node); // push added preceding siblings on in order and insert
2735 morphedNode.parentElement.insertBefore(node, morphedNode);
2736 }
2737 added.push(morphedNode);
2738 while (nextSibling != null) {
2739 stack.push(nextSibling);
2740 added.push(nextSibling); // here we are going in order, so push on as we scan, rather than add
2741 nextSibling = nextSibling.nextSibling;
2742 }
2743 while (stack.length > 0) {
2744 morphedNode.parentElement.insertBefore(stack.pop(), morphedNode.nextSibling);
2745 }
2746 return added;
2747 }
2748
2749 function findBestNodeMatch(newContent, oldNode, ctx) {
2750 let currentElement;
2751 currentElement = newContent.firstChild;
2752 let bestElement = currentElement;
2753 let score = 0;
2754 while (currentElement) {
2755 let newScore = scoreElement(currentElement, oldNode, ctx);
2756 if (newScore > score) {
2757 bestElement = currentElement;
2758 score = newScore;
2759 }
2760 currentElement = currentElement.nextSibling;
2761 }
2762 return bestElement;
2763 }
2764
2765 function scoreElement(node1, node2, ctx) {
2766 if (isSoftMatch(node1, node2)) {
2767 return .5 + getIdIntersectionCount(ctx, node1, node2);
2768 }
2769 return 0;
2770 }
2771
2772 function removeNode(tempNode, ctx) {
2773 removeIdsFromConsideration(ctx, tempNode);
2774 if (ctx.callbacks.beforeNodeRemoved(tempNode) === false) return;
2775
2776 tempNode.remove();
2777 ctx.callbacks.afterNodeRemoved(tempNode);
2778 }
2779
2780 //=============================================================================
2781 // ID Set Functions
2782 //=============================================================================
2783
2784 function isIdInConsideration(ctx, id) {
2785 return !ctx.deadIds.has(id);
2786 }
2787
2788 function idIsWithinNode(ctx, id, targetNode) {
2789 let idSet = ctx.idMap.get(targetNode) || EMPTY_SET;
2790 return idSet.has(id);
2791 }
2792
2793 function removeIdsFromConsideration(ctx, node) {
2794 let idSet = ctx.idMap.get(node) || EMPTY_SET;
2795 for (const id of idSet) {
2796 ctx.deadIds.add(id);
2797 }
2798 }
2799
2800 function getIdIntersectionCount(ctx, node1, node2) {
2801 let sourceSet = ctx.idMap.get(node1) || EMPTY_SET;
2802 let matchCount = 0;
2803 for (const id of sourceSet) {
2804 // a potential match is an id in the source and potentialIdsSet, but
2805 // that has not already been merged into the DOM
2806 if (isIdInConsideration(ctx, id) && idIsWithinNode(ctx, id, node2)) {
2807 ++matchCount;
2808 }
2809 }
2810 return matchCount;
2811 }
2812
2813 /**
2814 * A bottom up algorithm that finds all elements with ids inside of the node
2815 * argument and populates id sets for those nodes and all their parents, generating
2816 * a set of ids contained within all nodes for the entire hierarchy in the DOM
2817 *
2818 * @param node {Element}
2819 * @param {Map<Node, Set<String>>} idMap
2820 */
2821 function populateIdMapForNode(node, idMap) {
2822 let nodeParent = node.parentElement;
2823 // find all elements with an id property
2824 let idElements = node.querySelectorAll('[id]');
2825 for (const elt of idElements) {
2826 let current = elt;
2827 // walk up the parent hierarchy of that element, adding the id
2828 // of element to the parent's id set
2829 while (current !== nodeParent && current != null) {
2830 let idSet = idMap.get(current);
2831 // if the id set doesn't exist, create it and insert it in the map
2832 if (idSet == null) {
2833 idSet = new Set();
2834 idMap.set(current, idSet);
2835 }
2836 idSet.add(elt.id);
2837 current = current.parentElement;
2838 }
2839 }
2840 }
2841
2842 /**
2843 * This function computes a map of nodes to all ids contained within that node (inclusive of the
2844 * node). This map can be used to ask if two nodes have intersecting sets of ids, which allows
2845 * for a looser definition of "matching" than tradition id matching, and allows child nodes
2846 * to contribute to a parent nodes matching.
2847 *
2848 * @param {Element} oldContent the old content that will be morphed
2849 * @param {Element} newContent the new content to morph to
2850 * @returns {Map<Node, Set<String>>} a map of nodes to id sets for the
2851 */
2852 function createIdMap(oldContent, newContent) {
2853 let idMap = new Map();
2854 populateIdMapForNode(oldContent, idMap);
2855 populateIdMapForNode(newContent, idMap);
2856 return idMap;
2857 }
2858
2859 //=============================================================================
2860 // This is what ends up becoming the Idiomorph global object
2861 //=============================================================================
2862 return {
2863 morph,
2864 defaults
2865 }
2866 })();
2867
2868 function morphElements(currentElement, newElement, { callbacks, ...options } = {}) {
2869 Idiomorph.morph(currentElement, newElement, {
2870 ...options,
2871 callbacks: new DefaultIdiomorphCallbacks(callbacks)
2872 });
2873 }
2874
2875 function morphChildren(currentElement, newElement) {
2876 morphElements(currentElement, newElement.children, {
2877 morphStyle: "innerHTML"
2878 });
2879 }
2880
2881 class DefaultIdiomorphCallbacks {
2882 #beforeNodeMorphed
2883
2884 constructor({ beforeNodeMorphed } = {}) {
2885 this.#beforeNodeMorphed = beforeNodeMorphed || (() => true);
2886 }
2887
2888 beforeNodeAdded = (node) => {
2889 return !(node.id && node.hasAttribute("data-turbo-permanent") && document.getElementById(node.id))
2890 }
2891
2892 beforeNodeMorphed = (currentElement, newElement) => {
2893 if (currentElement instanceof Element) {
2894 if (!currentElement.hasAttribute("data-turbo-permanent") && this.#beforeNodeMorphed(currentElement, newElement)) {
2895 const event = dispatch("turbo:before-morph-element", {
2896 cancelable: true,
2897 target: currentElement,
2898 detail: { currentElement, newElement }
2899 });
2900
2901 return !event.defaultPrevented
2902 } else {
2903 return false
2904 }
2905 }
2906 }
2907
2908 beforeAttributeUpdated = (attributeName, target, mutationType) => {
2909 const event = dispatch("turbo:before-morph-attribute", {
2910 cancelable: true,
2911 target,
2912 detail: { attributeName, mutationType }
2913 });
2914
2915 return !event.defaultPrevented
2916 }
2917
2918 beforeNodeRemoved = (node) => {
2919 return this.beforeNodeMorphed(node)
2920 }
2921
2922 afterNodeMorphed = (currentElement, newElement) => {
2923 if (currentElement instanceof Element) {
2924 dispatch("turbo:morph-element", {
2925 target: currentElement,
2926 detail: { currentElement, newElement }
2927 });
2928 }
2929 }
2930 }
2931
2932 class MorphingFrameRenderer extends FrameRenderer {
2933 static renderElement(currentElement, newElement) {
2934 dispatch("turbo:before-frame-morph", {
2935 target: currentElement,
2936 detail: { currentElement, newElement }
2937 });
2938
2939 morphChildren(currentElement, newElement);
2940 }
2941
2942 async preservingPermanentElements(callback) {
2943 return await callback()
2944 }
2945 }
2946
2947 class ProgressBar {
2948 static animationDuration = 300 /*ms*/
2949
2950 static get defaultCSS() {
2951 return unindent`
2952 .turbo-progress-bar {
2953 position: fixed;
2954 display: block;
2955 top: 0;
2956 left: 0;
2957 height: 3px;
2958 background: #0076ff;
2959 z-index: 2147483647;
2960 transition:
2961 width ${ProgressBar.animationDuration}ms ease-out,
2962 opacity ${ProgressBar.animationDuration / 2}ms ${ProgressBar.animationDuration / 2}ms ease-in;
2963 transform: translate3d(0, 0, 0);
2964 }
2965 `
2966 }
2967
2968 hiding = false
2969 value = 0
2970 visible = false
2971
2972 constructor() {
2973 this.stylesheetElement = this.createStylesheetElement();
2974 this.progressElement = this.createProgressElement();
2975 this.installStylesheetElement();
2976 this.setValue(0);
2977 }
2978
2979 show() {
2980 if (!this.visible) {
2981 this.visible = true;
2982 this.installProgressElement();
2983 this.startTrickling();
2984 }
2985 }
2986
2987 hide() {
2988 if (this.visible && !this.hiding) {
2989 this.hiding = true;
2990 this.fadeProgressElement(() => {
2991 this.uninstallProgressElement();
2992 this.stopTrickling();
2993 this.visible = false;
2994 this.hiding = false;
2995 });
2996 }
2997 }
2998
2999 setValue(value) {
3000 this.value = value;
3001 this.refresh();
3002 }
3003
3004 // Private
3005
3006 installStylesheetElement() {
3007 document.head.insertBefore(this.stylesheetElement, document.head.firstChild);
3008 }
3009
3010 installProgressElement() {
3011 this.progressElement.style.width = "0";
3012 this.progressElement.style.opacity = "1";
3013 document.documentElement.insertBefore(this.progressElement, document.body);
3014 this.refresh();
3015 }
3016
3017 fadeProgressElement(callback) {
3018 this.progressElement.style.opacity = "0";
3019 setTimeout(callback, ProgressBar.animationDuration * 1.5);
3020 }
3021
3022 uninstallProgressElement() {
3023 if (this.progressElement.parentNode) {
3024 document.documentElement.removeChild(this.progressElement);
3025 }
3026 }
3027
3028 startTrickling() {
3029 if (!this.trickleInterval) {
3030 this.trickleInterval = window.setInterval(this.trickle, ProgressBar.animationDuration);
3031 }
3032 }
3033
3034 stopTrickling() {
3035 window.clearInterval(this.trickleInterval);
3036 delete this.trickleInterval;
3037 }
3038
3039 trickle = () => {
3040 this.setValue(this.value + Math.random() / 100);
3041 }
3042
3043 refresh() {
3044 requestAnimationFrame(() => {
3045 this.progressElement.style.width = `${10 + this.value * 90}%`;
3046 });
3047 }
3048
3049 createStylesheetElement() {
3050 const element = document.createElement("style");
3051 element.type = "text/css";
3052 element.textContent = ProgressBar.defaultCSS;
3053 const cspNonce = getCspNonce();
3054 if (cspNonce) {
3055 element.nonce = cspNonce;
3056 }
3057 return element
3058 }
3059
3060 createProgressElement() {
3061 const element = document.createElement("div");
3062 element.className = "turbo-progress-bar";
3063 return element
3064 }
3065 }
3066
3067 class HeadSnapshot extends Snapshot {
3068 detailsByOuterHTML = this.children
3069 .filter((element) => !elementIsNoscript(element))
3070 .map((element) => elementWithoutNonce(element))
3071 .reduce((result, element) => {
3072 const { outerHTML } = element;
3073 const details =
3074 outerHTML in result
3075 ? result[outerHTML]
3076 : {
3077 type: elementType(element),
3078 tracked: elementIsTracked(element),
3079 elements: []
3080 };
3081 return {
3082 ...result,
3083 [outerHTML]: {
3084 ...details,
3085 elements: [...details.elements, element]
3086 }
3087 }
3088 }, {})
3089
3090 get trackedElementSignature() {
3091 return Object.keys(this.detailsByOuterHTML)
3092 .filter((outerHTML) => this.detailsByOuterHTML[outerHTML].tracked)
3093 .join("")
3094 }
3095
3096 getScriptElementsNotInSnapshot(snapshot) {
3097 return this.getElementsMatchingTypeNotInSnapshot("script", snapshot)
3098 }
3099
3100 getStylesheetElementsNotInSnapshot(snapshot) {
3101 return this.getElementsMatchingTypeNotInSnapshot("stylesheet", snapshot)
3102 }
3103
3104 getElementsMatchingTypeNotInSnapshot(matchedType, snapshot) {
3105 return Object.keys(this.detailsByOuterHTML)
3106 .filter((outerHTML) => !(outerHTML in snapshot.detailsByOuterHTML))
3107 .map((outerHTML) => this.detailsByOuterHTML[outerHTML])
3108 .filter(({ type }) => type == matchedType)
3109 .map(({ elements: [element] }) => element)
3110 }
3111
3112 get provisionalElements() {
3113 return Object.keys(this.detailsByOuterHTML).reduce((result, outerHTML) => {
3114 const { type, tracked, elements } = this.detailsByOuterHTML[outerHTML];
3115 if (type == null && !tracked) {
3116 return [...result, ...elements]
3117 } else if (elements.length > 1) {
3118 return [...result, ...elements.slice(1)]
3119 } else {
3120 return result
3121 }
3122 }, [])
3123 }
3124
3125 getMetaValue(name) {
3126 const element = this.findMetaElementByName(name);
3127 return element ? element.getAttribute("content") : null
3128 }
3129
3130 findMetaElementByName(name) {
3131 return Object.keys(this.detailsByOuterHTML).reduce((result, outerHTML) => {
3132 const {
3133 elements: [element]
3134 } = this.detailsByOuterHTML[outerHTML];
3135 return elementIsMetaElementWithName(element, name) ? element : result
3136 }, undefined | undefined)
3137 }
3138 }
3139
3140 function elementType(element) {
3141 if (elementIsScript(element)) {
3142 return "script"
3143 } else if (elementIsStylesheet(element)) {
3144 return "stylesheet"
3145 }
3146 }
3147
3148 function elementIsTracked(element) {
3149 return element.getAttribute("data-turbo-track") == "reload"
3150 }
3151
3152 function elementIsScript(element) {
3153 const tagName = element.localName;
3154 return tagName == "script"
3155 }
3156
3157 function elementIsNoscript(element) {
3158 const tagName = element.localName;
3159 return tagName == "noscript"
3160 }
3161
3162 function elementIsStylesheet(element) {
3163 const tagName = element.localName;
3164 return tagName == "style" || (tagName == "link" && element.getAttribute("rel") == "stylesheet")
3165 }
3166
3167 function elementIsMetaElementWithName(element, name) {
3168 const tagName = element.localName;
3169 return tagName == "meta" && element.getAttribute("name") == name
3170 }
3171
3172 function elementWithoutNonce(element) {
3173 if (element.hasAttribute("nonce")) {
3174 element.setAttribute("nonce", "");
3175 }
3176
3177 return element
3178 }
3179
3180 class PageSnapshot extends Snapshot {
3181 static fromHTMLString(html = "") {
3182 return this.fromDocument(parseHTMLDocument(html))
3183 }
3184
3185 static fromElement(element) {
3186 return this.fromDocument(element.ownerDocument)
3187 }
3188
3189 static fromDocument({ documentElement, body, head }) {
3190 return new this(documentElement, body, new HeadSnapshot(head))
3191 }
3192
3193 constructor(documentElement, body, headSnapshot) {
3194 super(body);
3195 this.documentElement = documentElement;
3196 this.headSnapshot = headSnapshot;
3197 }
3198
3199 clone() {
3200 const clonedElement = this.element.cloneNode(true);
3201
3202 const selectElements = this.element.querySelectorAll("select");
3203 const clonedSelectElements = clonedElement.querySelectorAll("select");
3204
3205 for (const [index, source] of selectElements.entries()) {
3206 const clone = clonedSelectElements[index];
3207 for (const option of clone.selectedOptions) option.selected = false;
3208 for (const option of source.selectedOptions) clone.options[option.index].selected = true;
3209 }
3210
3211 for (const clonedPasswordInput of clonedElement.querySelectorAll('input[type="password"]')) {
3212 clonedPasswordInput.value = "";
3213 }
3214
3215 return new PageSnapshot(this.documentElement, clonedElement, this.headSnapshot)
3216 }
3217
3218 get lang() {
3219 return this.documentElement.getAttribute("lang")
3220 }
3221
3222 get headElement() {
3223 return this.headSnapshot.element
3224 }
3225
3226 get rootLocation() {
3227 const root = this.getSetting("root") ?? "/";
3228 return expandURL(root)
3229 }
3230
3231 get cacheControlValue() {
3232 return this.getSetting("cache-control")
3233 }
3234
3235 get isPreviewable() {
3236 return this.cacheControlValue != "no-preview"
3237 }
3238
3239 get isCacheable() {
3240 return this.cacheControlValue != "no-cache"
3241 }
3242
3243 get isVisitable() {
3244 return this.getSetting("visit-control") != "reload"
3245 }
3246
3247 get prefersViewTransitions() {
3248 return this.headSnapshot.getMetaValue("view-transition") === "same-origin"
3249 }
3250
3251 get shouldMorphPage() {
3252 return this.getSetting("refresh-method") === "morph"
3253 }
3254
3255 get shouldPreserveScrollPosition() {
3256 return this.getSetting("refresh-scroll") === "preserve"
3257 }
3258
3259 // Private
3260
3261 getSetting(name) {
3262 return this.headSnapshot.getMetaValue(`turbo-${name}`)
3263 }
3264 }
3265
3266 class ViewTransitioner {
3267 #viewTransitionStarted = false
3268 #lastOperation = Promise.resolve()
3269
3270 renderChange(useViewTransition, render) {
3271 if (useViewTransition && this.viewTransitionsAvailable && !this.#viewTransitionStarted) {
3272 this.#viewTransitionStarted = true;
3273 this.#lastOperation = this.#lastOperation.then(async () => {
3274 await document.startViewTransition(render).finished;
3275 });
3276 } else {
3277 this.#lastOperation = this.#lastOperation.then(render);
3278 }
3279
3280 return this.#lastOperation
3281 }
3282
3283 get viewTransitionsAvailable() {
3284 return document.startViewTransition
3285 }
3286 }
3287
3288 const defaultOptions = {
3289 action: "advance",
3290 historyChanged: false,
3291 visitCachedSnapshot: () => {},
3292 willRender: true,
3293 updateHistory: true,
3294 shouldCacheSnapshot: true,
3295 acceptsStreamResponse: false
3296 };
3297
3298 const TimingMetric = {
3299 visitStart: "visitStart",
3300 requestStart: "requestStart",
3301 requestEnd: "requestEnd",
3302 visitEnd: "visitEnd"
3303 };
3304
3305 const VisitState = {
3306 initialized: "initialized",
3307 started: "started",
3308 canceled: "canceled",
3309 failed: "failed",
3310 completed: "completed"
3311 };
3312
3313 const SystemStatusCode = {
3314 networkFailure: 0,
3315 timeoutFailure: -1,
3316 contentTypeMismatch: -2
3317 };
3318
3319 const Direction = {
3320 advance: "forward",
3321 restore: "back",
3322 replace: "none"
3323 };
3324
3325 class Visit {
3326 identifier = uuid() // Required by turbo-ios
3327 timingMetrics = {}
3328
3329 followedRedirect = false
3330 historyChanged = false
3331 scrolled = false
3332 shouldCacheSnapshot = true
3333 acceptsStreamResponse = false
3334 snapshotCached = false
3335 state = VisitState.initialized
3336 viewTransitioner = new ViewTransitioner()
3337
3338 constructor(delegate, location, restorationIdentifier, options = {}) {
3339 this.delegate = delegate;
3340 this.location = location;
3341 this.restorationIdentifier = restorationIdentifier || uuid();
3342
3343 const {
3344 action,
3345 historyChanged,
3346 referrer,
3347 snapshot,
3348 snapshotHTML,
3349 response,
3350 visitCachedSnapshot,
3351 willRender,
3352 updateHistory,
3353 shouldCacheSnapshot,
3354 acceptsStreamResponse,
3355 direction
3356 } = {
3357 ...defaultOptions,
3358 ...options
3359 };
3360 this.action = action;
3361 this.historyChanged = historyChanged;
3362 this.referrer = referrer;
3363 this.snapshot = snapshot;
3364 this.snapshotHTML = snapshotHTML;
3365 this.response = response;
3366 this.isSamePage = this.delegate.locationWithActionIsSamePage(this.location, this.action);
3367 this.isPageRefresh = this.view.isPageRefresh(this);
3368 this.visitCachedSnapshot = visitCachedSnapshot;
3369 this.willRender = willRender;
3370 this.updateHistory = updateHistory;
3371 this.scrolled = !willRender;
3372 this.shouldCacheSnapshot = shouldCacheSnapshot;
3373 this.acceptsStreamResponse = acceptsStreamResponse;
3374 this.direction = direction || Direction[action];
3375 }
3376
3377 get adapter() {
3378 return this.delegate.adapter
3379 }
3380
3381 get view() {
3382 return this.delegate.view
3383 }
3384
3385 get history() {
3386 return this.delegate.history
3387 }
3388
3389 get restorationData() {
3390 return this.history.getRestorationDataForIdentifier(this.restorationIdentifier)
3391 }
3392
3393 get silent() {
3394 return this.isSamePage
3395 }
3396
3397 start() {
3398 if (this.state == VisitState.initialized) {
3399 this.recordTimingMetric(TimingMetric.visitStart);
3400 this.state = VisitState.started;
3401 this.adapter.visitStarted(this);
3402 this.delegate.visitStarted(this);
3403 }
3404 }
3405
3406 cancel() {
3407 if (this.state == VisitState.started) {
3408 if (this.request) {
3409 this.request.cancel();
3410 }
3411 this.cancelRender();
3412 this.state = VisitState.canceled;
3413 }
3414 }
3415
3416 complete() {
3417 if (this.state == VisitState.started) {
3418 this.recordTimingMetric(TimingMetric.visitEnd);
3419 this.adapter.visitCompleted(this);
3420 this.state = VisitState.completed;
3421 this.followRedirect();
3422
3423 if (!this.followedRedirect) {
3424 this.delegate.visitCompleted(this);
3425 }
3426 }
3427 }
3428
3429 fail() {
3430 if (this.state == VisitState.started) {
3431 this.state = VisitState.failed;
3432 this.adapter.visitFailed(this);
3433 this.delegate.visitCompleted(this);
3434 }
3435 }
3436
3437 changeHistory() {
3438 if (!this.historyChanged && this.updateHistory) {
3439 const actionForHistory = this.location.href === this.referrer?.href ? "replace" : this.action;
3440 const method = getHistoryMethodForAction(actionForHistory);
3441 this.history.update(method, this.location, this.restorationIdentifier);
3442 this.historyChanged = true;
3443 }
3444 }
3445
3446 issueRequest() {
3447 if (this.hasPreloadedResponse()) {
3448 this.simulateRequest();
3449 } else if (this.shouldIssueRequest() && !this.request) {
3450 this.request = new FetchRequest(this, FetchMethod.get, this.location);
3451 this.request.perform();
3452 }
3453 }
3454
3455 simulateRequest() {
3456 if (this.response) {
3457 this.startRequest();
3458 this.recordResponse();
3459 this.finishRequest();
3460 }
3461 }
3462
3463 startRequest() {
3464 this.recordTimingMetric(TimingMetric.requestStart);
3465 this.adapter.visitRequestStarted(this);
3466 }
3467
3468 recordResponse(response = this.response) {
3469 this.response = response;
3470 if (response) {
3471 const { statusCode } = response;
3472 if (isSuccessful(statusCode)) {
3473 this.adapter.visitRequestCompleted(this);
3474 } else {
3475 this.adapter.visitRequestFailedWithStatusCode(this, statusCode);
3476 }
3477 }
3478 }
3479
3480 finishRequest() {
3481 this.recordTimingMetric(TimingMetric.requestEnd);
3482 this.adapter.visitRequestFinished(this);
3483 }
3484
3485 loadResponse() {
3486 if (this.response) {
3487 const { statusCode, responseHTML } = this.response;
3488 this.render(async () => {
3489 if (this.shouldCacheSnapshot) this.cacheSnapshot();
3490 if (this.view.renderPromise) await this.view.renderPromise;
3491
3492 if (isSuccessful(statusCode) && responseHTML != null) {
3493 const snapshot = PageSnapshot.fromHTMLString(responseHTML);
3494 await this.renderPageSnapshot(snapshot, false);
3495
3496 this.adapter.visitRendered(this);
3497 this.complete();
3498 } else {
3499 await this.view.renderError(PageSnapshot.fromHTMLString(responseHTML), this);
3500 this.adapter.visitRendered(this);
3501 this.fail();
3502 }
3503 });
3504 }
3505 }
3506
3507 getCachedSnapshot() {
3508 const snapshot = this.view.getCachedSnapshotForLocation(this.location) || this.getPreloadedSnapshot();
3509
3510 if (snapshot && (!getAnchor(this.location) || snapshot.hasAnchor(getAnchor(this.location)))) {
3511 if (this.action == "restore" || snapshot.isPreviewable) {
3512 return snapshot
3513 }
3514 }
3515 }
3516
3517 getPreloadedSnapshot() {
3518 if (this.snapshotHTML) {
3519 return PageSnapshot.fromHTMLString(this.snapshotHTML)
3520 }
3521 }
3522
3523 hasCachedSnapshot() {
3524 return this.getCachedSnapshot() != null
3525 }
3526
3527 loadCachedSnapshot() {
3528 const snapshot = this.getCachedSnapshot();
3529 if (snapshot) {
3530 const isPreview = this.shouldIssueRequest();
3531 this.render(async () => {
3532 this.cacheSnapshot();
3533 if (this.isSamePage || this.isPageRefresh) {
3534 this.adapter.visitRendered(this);
3535 } else {
3536 if (this.view.renderPromise) await this.view.renderPromise;
3537
3538 await this.renderPageSnapshot(snapshot, isPreview);
3539
3540 this.adapter.visitRendered(this);
3541 if (!isPreview) {
3542 this.complete();
3543 }
3544 }
3545 });
3546 }
3547 }
3548
3549 followRedirect() {
3550 if (this.redirectedToLocation && !this.followedRedirect && this.response?.redirected) {
3551 this.adapter.visitProposedToLocation(this.redirectedToLocation, {
3552 action: "replace",
3553 response: this.response,
3554 shouldCacheSnapshot: false,
3555 willRender: false
3556 });
3557 this.followedRedirect = true;
3558 }
3559 }
3560
3561 goToSamePageAnchor() {
3562 if (this.isSamePage) {
3563 this.render(async () => {
3564 this.cacheSnapshot();
3565 this.performScroll();
3566 this.changeHistory();
3567 this.adapter.visitRendered(this);
3568 });
3569 }
3570 }
3571
3572 // Fetch request delegate
3573
3574 prepareRequest(request) {
3575 if (this.acceptsStreamResponse) {
3576 request.acceptResponseType(StreamMessage.contentType);
3577 }
3578 }
3579
3580 requestStarted() {
3581 this.startRequest();
3582 }
3583
3584 requestPreventedHandlingResponse(_request, _response) {}
3585
3586 async requestSucceededWithResponse(request, response) {
3587 const responseHTML = await response.responseHTML;
3588 const { redirected, statusCode } = response;
3589 if (responseHTML == undefined) {
3590 this.recordResponse({
3591 statusCode: SystemStatusCode.contentTypeMismatch,
3592 redirected
3593 });
3594 } else {
3595 this.redirectedToLocation = response.redirected ? response.location : undefined;
3596 this.recordResponse({ statusCode: statusCode, responseHTML, redirected });
3597 }
3598 }
3599
3600 async requestFailedWithResponse(request, response) {
3601 const responseHTML = await response.responseHTML;
3602 const { redirected, statusCode } = response;
3603 if (responseHTML == undefined) {
3604 this.recordResponse({
3605 statusCode: SystemStatusCode.contentTypeMismatch,
3606 redirected
3607 });
3608 } else {
3609 this.recordResponse({ statusCode: statusCode, responseHTML, redirected });
3610 }
3611 }
3612
3613 requestErrored(_request, _error) {
3614 this.recordResponse({
3615 statusCode: SystemStatusCode.networkFailure,
3616 redirected: false
3617 });
3618 }
3619
3620 requestFinished() {
3621 this.finishRequest();
3622 }
3623
3624 // Scrolling
3625
3626 performScroll() {
3627 if (!this.scrolled && !this.view.forceReloaded && !this.view.shouldPreserveScrollPosition(this)) {
3628 if (this.action == "restore") {
3629 this.scrollToRestoredPosition() || this.scrollToAnchor() || this.view.scrollToTop();
3630 } else {
3631 this.scrollToAnchor() || this.view.scrollToTop();
3632 }
3633 if (this.isSamePage) {
3634 this.delegate.visitScrolledToSamePageLocation(this.view.lastRenderedLocation, this.location);
3635 }
3636
3637 this.scrolled = true;
3638 }
3639 }
3640
3641 scrollToRestoredPosition() {
3642 const { scrollPosition } = this.restorationData;
3643 if (scrollPosition) {
3644 this.view.scrollToPosition(scrollPosition);
3645 return true
3646 }
3647 }
3648
3649 scrollToAnchor() {
3650 const anchor = getAnchor(this.location);
3651 if (anchor != null) {
3652 this.view.scrollToAnchor(anchor);
3653 return true
3654 }
3655 }
3656
3657 // Instrumentation
3658
3659 recordTimingMetric(metric) {
3660 this.timingMetrics[metric] = new Date().getTime();
3661 }
3662
3663 getTimingMetrics() {
3664 return { ...this.timingMetrics }
3665 }
3666
3667 // Private
3668
3669 getHistoryMethodForAction(action) {
3670 switch (action) {
3671 case "replace":
3672 return history.replaceState
3673 case "advance":
3674 case "restore":
3675 return history.pushState
3676 }
3677 }
3678
3679 hasPreloadedResponse() {
3680 return typeof this.response == "object"
3681 }
3682
3683 shouldIssueRequest() {
3684 if (this.isSamePage) {
3685 return false
3686 } else if (this.action == "restore") {
3687 return !this.hasCachedSnapshot()
3688 } else {
3689 return this.willRender
3690 }
3691 }
3692
3693 cacheSnapshot() {
3694 if (!this.snapshotCached) {
3695 this.view.cacheSnapshot(this.snapshot).then((snapshot) => snapshot && this.visitCachedSnapshot(snapshot));
3696 this.snapshotCached = true;
3697 }
3698 }
3699
3700 async render(callback) {
3701 this.cancelRender();
3702 await new Promise((resolve) => {
3703 this.frame =
3704 document.visibilityState === "hidden" ? setTimeout(() => resolve(), 0) : requestAnimationFrame(() => resolve());
3705 });
3706 await callback();
3707 delete this.frame;
3708 }
3709
3710 async renderPageSnapshot(snapshot, isPreview) {
3711 await this.viewTransitioner.renderChange(this.view.shouldTransitionTo(snapshot), async () => {
3712 await this.view.renderPage(snapshot, isPreview, this.willRender, this);
3713 this.performScroll();
3714 });
3715 }
3716
3717 cancelRender() {
3718 if (this.frame) {
3719 cancelAnimationFrame(this.frame);
3720 delete this.frame;
3721 }
3722 }
3723 }
3724
3725 function isSuccessful(statusCode) {
3726 return statusCode >= 200 && statusCode < 300
3727 }
3728
3729 class BrowserAdapter {
3730 progressBar = new ProgressBar()
3731
3732 constructor(session) {
3733 this.session = session;
3734 }
3735
3736 visitProposedToLocation(location, options) {
3737 if (locationIsVisitable(location, this.navigator.rootLocation)) {
3738 this.navigator.startVisit(location, options?.restorationIdentifier || uuid(), options);
3739 } else {
3740 window.location.href = location.toString();
3741 }
3742 }
3743
3744 visitStarted(visit) {
3745 this.location = visit.location;
3746 visit.loadCachedSnapshot();
3747 visit.issueRequest();
3748 visit.goToSamePageAnchor();
3749 }
3750
3751 visitRequestStarted(visit) {
3752 this.progressBar.setValue(0);
3753 if (visit.hasCachedSnapshot() || visit.action != "restore") {
3754 this.showVisitProgressBarAfterDelay();
3755 } else {
3756 this.showProgressBar();
3757 }
3758 }
3759
3760 visitRequestCompleted(visit) {
3761 visit.loadResponse();
3762 }
3763
3764 visitRequestFailedWithStatusCode(visit, statusCode) {
3765 switch (statusCode) {
3766 case SystemStatusCode.networkFailure:
3767 case SystemStatusCode.timeoutFailure:
3768 case SystemStatusCode.contentTypeMismatch:
3769 return this.reload({
3770 reason: "request_failed",
3771 context: {
3772 statusCode
3773 }
3774 })
3775 default:
3776 return visit.loadResponse()
3777 }
3778 }
3779
3780 visitRequestFinished(_visit) {}
3781
3782 visitCompleted(_visit) {
3783 this.progressBar.setValue(1);
3784 this.hideVisitProgressBar();
3785 }
3786
3787 pageInvalidated(reason) {
3788 this.reload(reason);
3789 }
3790
3791 visitFailed(_visit) {
3792 this.progressBar.setValue(1);
3793 this.hideVisitProgressBar();
3794 }
3795
3796 visitRendered(_visit) {}
3797
3798 // Form Submission Delegate
3799
3800 formSubmissionStarted(_formSubmission) {
3801 this.progressBar.setValue(0);
3802 this.showFormProgressBarAfterDelay();
3803 }
3804
3805 formSubmissionFinished(_formSubmission) {
3806 this.progressBar.setValue(1);
3807 this.hideFormProgressBar();
3808 }
3809
3810 // Private
3811
3812 showVisitProgressBarAfterDelay() {
3813 this.visitProgressBarTimeout = window.setTimeout(this.showProgressBar, this.session.progressBarDelay);
3814 }
3815
3816 hideVisitProgressBar() {
3817 this.progressBar.hide();
3818 if (this.visitProgressBarTimeout != null) {
3819 window.clearTimeout(this.visitProgressBarTimeout);
3820 delete this.visitProgressBarTimeout;
3821 }
3822 }
3823
3824 showFormProgressBarAfterDelay() {
3825 if (this.formProgressBarTimeout == null) {
3826 this.formProgressBarTimeout = window.setTimeout(this.showProgressBar, this.session.progressBarDelay);
3827 }
3828 }
3829
3830 hideFormProgressBar() {
3831 this.progressBar.hide();
3832 if (this.formProgressBarTimeout != null) {
3833 window.clearTimeout(this.formProgressBarTimeout);
3834 delete this.formProgressBarTimeout;
3835 }
3836 }
3837
3838 showProgressBar = () => {
3839 this.progressBar.show();
3840 }
3841
3842 reload(reason) {
3843 dispatch("turbo:reload", { detail: reason });
3844
3845 window.location.href = this.location?.toString() || window.location.href;
3846 }
3847
3848 get navigator() {
3849 return this.session.navigator
3850 }
3851 }
3852
3853 class CacheObserver {
3854 selector = "[data-turbo-temporary]"
3855 deprecatedSelector = "[data-turbo-cache=false]"
3856
3857 started = false
3858
3859 start() {
3860 if (!this.started) {
3861 this.started = true;
3862 addEventListener("turbo:before-cache", this.removeTemporaryElements, false);
3863 }
3864 }
3865
3866 stop() {
3867 if (this.started) {
3868 this.started = false;
3869 removeEventListener("turbo:before-cache", this.removeTemporaryElements, false);
3870 }
3871 }
3872
3873 removeTemporaryElements = (_event) => {
3874 for (const element of this.temporaryElements) {
3875 element.remove();
3876 }
3877 }
3878
3879 get temporaryElements() {
3880 return [...document.querySelectorAll(this.selector), ...this.temporaryElementsWithDeprecation]
3881 }
3882
3883 get temporaryElementsWithDeprecation() {
3884 const elements = document.querySelectorAll(this.deprecatedSelector);
3885
3886 if (elements.length) {
3887 console.warn(
3888 `The ${this.deprecatedSelector} selector is deprecated and will be removed in a future version. Use ${this.selector} instead.`
3889 );
3890 }
3891
3892 return [...elements]
3893 }
3894 }
3895
3896 class FrameRedirector {
3897 constructor(session, element) {
3898 this.session = session;
3899 this.element = element;
3900 this.linkInterceptor = new LinkInterceptor(this, element);
3901 this.formSubmitObserver = new FormSubmitObserver(this, element);
3902 }
3903
3904 start() {
3905 this.linkInterceptor.start();
3906 this.formSubmitObserver.start();
3907 }
3908
3909 stop() {
3910 this.linkInterceptor.stop();
3911 this.formSubmitObserver.stop();
3912 }
3913
3914 // Link interceptor delegate
3915
3916 shouldInterceptLinkClick(element, _location, _event) {
3917 return this.#shouldRedirect(element)
3918 }
3919
3920 linkClickIntercepted(element, url, event) {
3921 const frame = this.#findFrameElement(element);
3922 if (frame) {
3923 frame.delegate.linkClickIntercepted(element, url, event);
3924 }
3925 }
3926
3927 // Form submit observer delegate
3928
3929 willSubmitForm(element, submitter) {
3930 return (
3931 element.closest("turbo-frame") == null &&
3932 this.#shouldSubmit(element, submitter) &&
3933 this.#shouldRedirect(element, submitter)
3934 )
3935 }
3936
3937 formSubmitted(element, submitter) {
3938 const frame = this.#findFrameElement(element, submitter);
3939 if (frame) {
3940 frame.delegate.formSubmitted(element, submitter);
3941 }
3942 }
3943
3944 #shouldSubmit(form, submitter) {
3945 const action = getAction$1(form, submitter);
3946 const meta = this.element.ownerDocument.querySelector(`meta[name="turbo-root"]`);
3947 const rootLocation = expandURL(meta?.content ?? "/");
3948
3949 return this.#shouldRedirect(form, submitter) && locationIsVisitable(action, rootLocation)
3950 }
3951
3952 #shouldRedirect(element, submitter) {
3953 const isNavigatable =
3954 element instanceof HTMLFormElement
3955 ? this.session.submissionIsNavigatable(element, submitter)
3956 : this.session.elementIsNavigatable(element);
3957
3958 if (isNavigatable) {
3959 const frame = this.#findFrameElement(element, submitter);
3960 return frame ? frame != element.closest("turbo-frame") : false
3961 } else {
3962 return false
3963 }
3964 }
3965
3966 #findFrameElement(element, submitter) {
3967 const id = submitter?.getAttribute("data-turbo-frame") || element.getAttribute("data-turbo-frame");
3968 if (id && id != "_top") {
3969 const frame = this.element.querySelector(`#${id}:not([disabled])`);
3970 if (frame instanceof FrameElement) {
3971 return frame
3972 }
3973 }
3974 }
3975 }
3976
3977 class History {
3978 location
3979 restorationIdentifier = uuid()
3980 restorationData = {}
3981 started = false
3982 pageLoaded = false
3983 currentIndex = 0
3984
3985 constructor(delegate) {
3986 this.delegate = delegate;
3987 }
3988
3989 start() {
3990 if (!this.started) {
3991 addEventListener("popstate", this.onPopState, false);
3992 addEventListener("load", this.onPageLoad, false);
3993 this.currentIndex = history.state?.turbo?.restorationIndex || 0;
3994 this.started = true;
3995 this.replace(new URL(window.location.href));
3996 }
3997 }
3998
3999 stop() {
4000 if (this.started) {
4001 removeEventListener("popstate", this.onPopState, false);
4002 removeEventListener("load", this.onPageLoad, false);
4003 this.started = false;
4004 }
4005 }
4006
4007 push(location, restorationIdentifier) {
4008 this.update(history.pushState, location, restorationIdentifier);
4009 }
4010
4011 replace(location, restorationIdentifier) {
4012 this.update(history.replaceState, location, restorationIdentifier);
4013 }
4014
4015 update(method, location, restorationIdentifier = uuid()) {
4016 if (method === history.pushState) ++this.currentIndex;
4017
4018 const state = { turbo: { restorationIdentifier, restorationIndex: this.currentIndex } };
4019 method.call(history, state, "", location.href);
4020 this.location = location;
4021 this.restorationIdentifier = restorationIdentifier;
4022 }
4023
4024 // Restoration data
4025
4026 getRestorationDataForIdentifier(restorationIdentifier) {
4027 return this.restorationData[restorationIdentifier] || {}
4028 }
4029
4030 updateRestorationData(additionalData) {
4031 const { restorationIdentifier } = this;
4032 const restorationData = this.restorationData[restorationIdentifier];
4033 this.restorationData[restorationIdentifier] = {
4034 ...restorationData,
4035 ...additionalData
4036 };
4037 }
4038
4039 // Scroll restoration
4040
4041 assumeControlOfScrollRestoration() {
4042 if (!this.previousScrollRestoration) {
4043 this.previousScrollRestoration = history.scrollRestoration ?? "auto";
4044 history.scrollRestoration = "manual";
4045 }
4046 }
4047
4048 relinquishControlOfScrollRestoration() {
4049 if (this.previousScrollRestoration) {
4050 history.scrollRestoration = this.previousScrollRestoration;
4051 delete this.previousScrollRestoration;
4052 }
4053 }
4054
4055 // Event handlers
4056
4057 onPopState = (event) => {
4058 if (this.shouldHandlePopState()) {
4059 const { turbo } = event.state || {};
4060 if (turbo) {
4061 this.location = new URL(window.location.href);
4062 const { restorationIdentifier, restorationIndex } = turbo;
4063 this.restorationIdentifier = restorationIdentifier;
4064 const direction = restorationIndex > this.currentIndex ? "forward" : "back";
4065 this.delegate.historyPoppedToLocationWithRestorationIdentifierAndDirection(this.location, restorationIdentifier, direction);
4066 this.currentIndex = restorationIndex;
4067 }
4068 }
4069 }
4070
4071 onPageLoad = async (_event) => {
4072 await nextMicrotask();
4073 this.pageLoaded = true;
4074 }
4075
4076 // Private
4077
4078 shouldHandlePopState() {
4079 // Safari dispatches a popstate event after window's load event, ignore it
4080 return this.pageIsLoaded()
4081 }
4082
4083 pageIsLoaded() {
4084 return this.pageLoaded || document.readyState == "complete"
4085 }
4086 }
4087
4088 class LinkPrefetchObserver {
4089 started = false
4090 #prefetchedLink = null
4091
4092 constructor(delegate, eventTarget) {
4093 this.delegate = delegate;
4094 this.eventTarget = eventTarget;
4095 }
4096
4097 start() {
4098 if (this.started) return
4099
4100 if (this.eventTarget.readyState === "loading") {
4101 this.eventTarget.addEventListener("DOMContentLoaded", this.#enable, { once: true });
4102 } else {
4103 this.#enable();
4104 }
4105 }
4106
4107 stop() {
4108 if (!this.started) return
4109
4110 this.eventTarget.removeEventListener("mouseenter", this.#tryToPrefetchRequest, {
4111 capture: true,
4112 passive: true
4113 });
4114 this.eventTarget.removeEventListener("mouseleave", this.#cancelRequestIfObsolete, {
4115 capture: true,
4116 passive: true
4117 });
4118
4119 this.eventTarget.removeEventListener("turbo:before-fetch-request", this.#tryToUsePrefetchedRequest, true);
4120 this.started = false;
4121 }
4122
4123 #enable = () => {
4124 this.eventTarget.addEventListener("mouseenter", this.#tryToPrefetchRequest, {
4125 capture: true,
4126 passive: true
4127 });
4128 this.eventTarget.addEventListener("mouseleave", this.#cancelRequestIfObsolete, {
4129 capture: true,
4130 passive: true
4131 });
4132
4133 this.eventTarget.addEventListener("turbo:before-fetch-request", this.#tryToUsePrefetchedRequest, true);
4134 this.started = true;
4135 }
4136
4137 #tryToPrefetchRequest = (event) => {
4138 if (getMetaContent("turbo-prefetch") === "false") return
4139
4140 const target = event.target;
4141 const isLink = target.matches && target.matches("a[href]:not([target^=_]):not([download])");
4142
4143 if (isLink && this.#isPrefetchable(target)) {
4144 const link = target;
4145 const location = getLocationForLink(link);
4146
4147 if (this.delegate.canPrefetchRequestToLocation(link, location)) {
4148 this.#prefetchedLink = link;
4149
4150 const fetchRequest = new FetchRequest(
4151 this,
4152 FetchMethod.get,
4153 location,
4154 new URLSearchParams(),
4155 target
4156 );
4157
4158 prefetchCache.setLater(location.toString(), fetchRequest, this.#cacheTtl);
4159 }
4160 }
4161 }
4162
4163 #cancelRequestIfObsolete = (event) => {
4164 if (event.target === this.#prefetchedLink) this.#cancelPrefetchRequest();
4165 }
4166
4167 #cancelPrefetchRequest = () => {
4168 prefetchCache.clear();
4169 this.#prefetchedLink = null;
4170 }
4171
4172 #tryToUsePrefetchedRequest = (event) => {
4173 if (event.target.tagName !== "FORM" && event.detail.fetchOptions.method === "GET") {
4174 const cached = prefetchCache.get(event.detail.url.toString());
4175
4176 if (cached) {
4177 // User clicked link, use cache response
4178 event.detail.fetchRequest = cached;
4179 }
4180
4181 prefetchCache.clear();
4182 }
4183 }
4184
4185 prepareRequest(request) {
4186 const link = request.target;
4187
4188 request.headers["X-Sec-Purpose"] = "prefetch";
4189
4190 const turboFrame = link.closest("turbo-frame");
4191 const turboFrameTarget = link.getAttribute("data-turbo-frame") || turboFrame?.getAttribute("target") || turboFrame?.id;
4192
4193 if (turboFrameTarget && turboFrameTarget !== "_top") {
4194 request.headers["Turbo-Frame"] = turboFrameTarget;
4195 }
4196 }
4197
4198 // Fetch request interface
4199
4200 requestSucceededWithResponse() {}
4201
4202 requestStarted(fetchRequest) {}
4203
4204 requestErrored(fetchRequest) {}
4205
4206 requestFinished(fetchRequest) {}
4207
4208 requestPreventedHandlingResponse(fetchRequest, fetchResponse) {}
4209
4210 requestFailedWithResponse(fetchRequest, fetchResponse) {}
4211
4212 get #cacheTtl() {
4213 return Number(getMetaContent("turbo-prefetch-cache-time")) || cacheTtl
4214 }
4215
4216 #isPrefetchable(link) {
4217 const href = link.getAttribute("href");
4218
4219 if (!href) return false
4220
4221 if (unfetchableLink(link)) return false
4222 if (linkToTheSamePage(link)) return false
4223 if (linkOptsOut(link)) return false
4224 if (nonSafeLink(link)) return false
4225 if (eventPrevented(link)) return false
4226
4227 return true
4228 }
4229 }
4230
4231 const unfetchableLink = (link) => {
4232 return link.origin !== document.location.origin || !["http:", "https:"].includes(link.protocol) || link.hasAttribute("target")
4233 };
4234
4235 const linkToTheSamePage = (link) => {
4236 return (link.pathname + link.search === document.location.pathname + document.location.search) || link.href.startsWith("#")
4237 };
4238
4239 const linkOptsOut = (link) => {
4240 if (link.getAttribute("data-turbo-prefetch") === "false") return true
4241 if (link.getAttribute("data-turbo") === "false") return true
4242
4243 const turboPrefetchParent = findClosestRecursively(link, "[data-turbo-prefetch]");
4244 if (turboPrefetchParent && turboPrefetchParent.getAttribute("data-turbo-prefetch") === "false") return true
4245
4246 return false
4247 };
4248
4249 const nonSafeLink = (link) => {
4250 const turboMethod = link.getAttribute("data-turbo-method");
4251 if (turboMethod && turboMethod.toLowerCase() !== "get") return true
4252
4253 if (isUJS(link)) return true
4254 if (link.hasAttribute("data-turbo-confirm")) return true
4255 if (link.hasAttribute("data-turbo-stream")) return true
4256
4257 return false
4258 };
4259
4260 const isUJS = (link) => {
4261 return link.hasAttribute("data-remote") || link.hasAttribute("data-behavior") || link.hasAttribute("data-confirm") || link.hasAttribute("data-method")
4262 };
4263
4264 const eventPrevented = (link) => {
4265 const event = dispatch("turbo:before-prefetch", { target: link, cancelable: true });
4266 return event.defaultPrevented
4267 };
4268
4269 class Navigator {
4270 constructor(delegate) {
4271 this.delegate = delegate;
4272 }
4273
4274 proposeVisit(location, options = {}) {
4275 if (this.delegate.allowsVisitingLocationWithAction(location, options.action)) {
4276 this.delegate.visitProposedToLocation(location, options);
4277 }
4278 }
4279
4280 startVisit(locatable, restorationIdentifier, options = {}) {
4281 this.stop();
4282 this.currentVisit = new Visit(this, expandURL(locatable), restorationIdentifier, {
4283 referrer: this.location,
4284 ...options
4285 });
4286 this.currentVisit.start();
4287 }
4288
4289 submitForm(form, submitter) {
4290 this.stop();
4291 this.formSubmission = new FormSubmission(this, form, submitter, true);
4292
4293 this.formSubmission.start();
4294 }
4295
4296 stop() {
4297 if (this.formSubmission) {
4298 this.formSubmission.stop();
4299 delete this.formSubmission;
4300 }
4301
4302 if (this.currentVisit) {
4303 this.currentVisit.cancel();
4304 delete this.currentVisit;
4305 }
4306 }
4307
4308 get adapter() {
4309 return this.delegate.adapter
4310 }
4311
4312 get view() {
4313 return this.delegate.view
4314 }
4315
4316 get rootLocation() {
4317 return this.view.snapshot.rootLocation
4318 }
4319
4320 get history() {
4321 return this.delegate.history
4322 }
4323
4324 // Form submission delegate
4325
4326 formSubmissionStarted(formSubmission) {
4327 // Not all adapters implement formSubmissionStarted
4328 if (typeof this.adapter.formSubmissionStarted === "function") {
4329 this.adapter.formSubmissionStarted(formSubmission);
4330 }
4331 }
4332
4333 async formSubmissionSucceededWithResponse(formSubmission, fetchResponse) {
4334 if (formSubmission == this.formSubmission) {
4335 const responseHTML = await fetchResponse.responseHTML;
4336 if (responseHTML) {
4337 const shouldCacheSnapshot = formSubmission.isSafe;
4338 if (!shouldCacheSnapshot) {
4339 this.view.clearSnapshotCache();
4340 }
4341
4342 const { statusCode, redirected } = fetchResponse;
4343 const action = this.#getActionForFormSubmission(formSubmission, fetchResponse);
4344 const visitOptions = {
4345 action,
4346 shouldCacheSnapshot,
4347 response: { statusCode, responseHTML, redirected }
4348 };
4349 this.proposeVisit(fetchResponse.location, visitOptions);
4350 }
4351 }
4352 }
4353
4354 async formSubmissionFailedWithResponse(formSubmission, fetchResponse) {
4355 const responseHTML = await fetchResponse.responseHTML;
4356
4357 if (responseHTML) {
4358 const snapshot = PageSnapshot.fromHTMLString(responseHTML);
4359 if (fetchResponse.serverError) {
4360 await this.view.renderError(snapshot, this.currentVisit);
4361 } else {
4362 await this.view.renderPage(snapshot, false, true, this.currentVisit);
4363 }
4364 if(!snapshot.shouldPreserveScrollPosition) {
4365 this.view.scrollToTop();
4366 }
4367 this.view.clearSnapshotCache();
4368 }
4369 }
4370
4371 formSubmissionErrored(formSubmission, error) {
4372 console.error(error);
4373 }
4374
4375 formSubmissionFinished(formSubmission) {
4376 // Not all adapters implement formSubmissionFinished
4377 if (typeof this.adapter.formSubmissionFinished === "function") {
4378 this.adapter.formSubmissionFinished(formSubmission);
4379 }
4380 }
4381
4382 // Visit delegate
4383
4384 visitStarted(visit) {
4385 this.delegate.visitStarted(visit);
4386 }
4387
4388 visitCompleted(visit) {
4389 this.delegate.visitCompleted(visit);
4390 delete this.currentVisit;
4391 }
4392
4393 locationWithActionIsSamePage(location, action) {
4394 const anchor = getAnchor(location);
4395 const currentAnchor = getAnchor(this.view.lastRenderedLocation);
4396 const isRestorationToTop = action === "restore" && typeof anchor === "undefined";
4397
4398 return (
4399 action !== "replace" &&
4400 getRequestURL(location) === getRequestURL(this.view.lastRenderedLocation) &&
4401 (isRestorationToTop || (anchor != null && anchor !== currentAnchor))
4402 )
4403 }
4404
4405 visitScrolledToSamePageLocation(oldURL, newURL) {
4406 this.delegate.visitScrolledToSamePageLocation(oldURL, newURL);
4407 }
4408
4409 // Visits
4410
4411 get location() {
4412 return this.history.location
4413 }
4414
4415 get restorationIdentifier() {
4416 return this.history.restorationIdentifier
4417 }
4418
4419 #getActionForFormSubmission(formSubmission, fetchResponse) {
4420 const { submitter, formElement } = formSubmission;
4421 return getVisitAction(submitter, formElement) || this.#getDefaultAction(fetchResponse)
4422 }
4423
4424 #getDefaultAction(fetchResponse) {
4425 const sameLocationRedirect = fetchResponse.redirected && fetchResponse.location.href === this.location?.href;
4426 return sameLocationRedirect ? "replace" : "advance"
4427 }
4428 }
4429
4430 const PageStage = {
4431 initial: 0,
4432 loading: 1,
4433 interactive: 2,
4434 complete: 3
4435 };
4436
4437 class PageObserver {
4438 stage = PageStage.initial
4439 started = false
4440
4441 constructor(delegate) {
4442 this.delegate = delegate;
4443 }
4444
4445 start() {
4446 if (!this.started) {
4447 if (this.stage == PageStage.initial) {
4448 this.stage = PageStage.loading;
4449 }
4450 document.addEventListener("readystatechange", this.interpretReadyState, false);
4451 addEventListener("pagehide", this.pageWillUnload, false);
4452 this.started = true;
4453 }
4454 }
4455
4456 stop() {
4457 if (this.started) {
4458 document.removeEventListener("readystatechange", this.interpretReadyState, false);
4459 removeEventListener("pagehide", this.pageWillUnload, false);
4460 this.started = false;
4461 }
4462 }
4463
4464 interpretReadyState = () => {
4465 const { readyState } = this;
4466 if (readyState == "interactive") {
4467 this.pageIsInteractive();
4468 } else if (readyState == "complete") {
4469 this.pageIsComplete();
4470 }
4471 }
4472
4473 pageIsInteractive() {
4474 if (this.stage == PageStage.loading) {
4475 this.stage = PageStage.interactive;
4476 this.delegate.pageBecameInteractive();
4477 }
4478 }
4479
4480 pageIsComplete() {
4481 this.pageIsInteractive();
4482 if (this.stage == PageStage.interactive) {
4483 this.stage = PageStage.complete;
4484 this.delegate.pageLoaded();
4485 }
4486 }
4487
4488 pageWillUnload = () => {
4489 this.delegate.pageWillUnload();
4490 }
4491
4492 get readyState() {
4493 return document.readyState
4494 }
4495 }
4496
4497 class ScrollObserver {
4498 started = false
4499
4500 constructor(delegate) {
4501 this.delegate = delegate;
4502 }
4503
4504 start() {
4505 if (!this.started) {
4506 addEventListener("scroll", this.onScroll, false);
4507 this.onScroll();
4508 this.started = true;
4509 }
4510 }
4511
4512 stop() {
4513 if (this.started) {
4514 removeEventListener("scroll", this.onScroll, false);
4515 this.started = false;
4516 }
4517 }
4518
4519 onScroll = () => {
4520 this.updatePosition({ x: window.pageXOffset, y: window.pageYOffset });
4521 }
4522
4523 // Private
4524
4525 updatePosition(position) {
4526 this.delegate.scrollPositionChanged(position);
4527 }
4528 }
4529
4530 class StreamMessageRenderer {
4531 render({ fragment }) {
4532 Bardo.preservingPermanentElements(this, getPermanentElementMapForFragment(fragment), () => {
4533 withAutofocusFromFragment(fragment, () => {
4534 withPreservedFocus(() => {
4535 document.documentElement.appendChild(fragment);
4536 });
4537 });
4538 });
4539 }
4540
4541 // Bardo delegate
4542
4543 enteringBardo(currentPermanentElement, newPermanentElement) {
4544 newPermanentElement.replaceWith(currentPermanentElement.cloneNode(true));
4545 }
4546
4547 leavingBardo() {}
4548 }
4549
4550 function getPermanentElementMapForFragment(fragment) {
4551 const permanentElementsInDocument = queryPermanentElementsAll(document.documentElement);
4552 const permanentElementMap = {};
4553 for (const permanentElementInDocument of permanentElementsInDocument) {
4554 const { id } = permanentElementInDocument;
4555
4556 for (const streamElement of fragment.querySelectorAll("turbo-stream")) {
4557 const elementInStream = getPermanentElementById(streamElement.templateElement.content, id);
4558
4559 if (elementInStream) {
4560 permanentElementMap[id] = [permanentElementInDocument, elementInStream];
4561 }
4562 }
4563 }
4564
4565 return permanentElementMap
4566 }
4567
4568 async function withAutofocusFromFragment(fragment, callback) {
4569 const generatedID = `turbo-stream-autofocus-${uuid()}`;
4570 const turboStreams = fragment.querySelectorAll("turbo-stream");
4571 const elementWithAutofocus = firstAutofocusableElementInStreams(turboStreams);
4572 let willAutofocusId = null;
4573
4574 if (elementWithAutofocus) {
4575 if (elementWithAutofocus.id) {
4576 willAutofocusId = elementWithAutofocus.id;
4577 } else {
4578 willAutofocusId = generatedID;
4579 }
4580
4581 elementWithAutofocus.id = willAutofocusId;
4582 }
4583
4584 callback();
4585 await nextRepaint();
4586
4587 const hasNoActiveElement = document.activeElement == null || document.activeElement == document.body;
4588
4589 if (hasNoActiveElement && willAutofocusId) {
4590 const elementToAutofocus = document.getElementById(willAutofocusId);
4591
4592 if (elementIsFocusable(elementToAutofocus)) {
4593 elementToAutofocus.focus();
4594 }
4595 if (elementToAutofocus && elementToAutofocus.id == generatedID) {
4596 elementToAutofocus.removeAttribute("id");
4597 }
4598 }
4599 }
4600
4601 async function withPreservedFocus(callback) {
4602 const [activeElementBeforeRender, activeElementAfterRender] = await around(callback, () => document.activeElement);
4603
4604 const restoreFocusTo = activeElementBeforeRender && activeElementBeforeRender.id;
4605
4606 if (restoreFocusTo) {
4607 const elementToFocus = document.getElementById(restoreFocusTo);
4608
4609 if (elementIsFocusable(elementToFocus) && elementToFocus != activeElementAfterRender) {
4610 elementToFocus.focus();
4611 }
4612 }
4613 }
4614
4615 function firstAutofocusableElementInStreams(nodeListOfStreamElements) {
4616 for (const streamElement of nodeListOfStreamElements) {
4617 const elementWithAutofocus = queryAutofocusableElement(streamElement.templateElement.content);
4618
4619 if (elementWithAutofocus) return elementWithAutofocus
4620 }
4621
4622 return null
4623 }
4624
4625 class StreamObserver {
4626 sources = new Set()
4627 #started = false
4628
4629 constructor(delegate) {
4630 this.delegate = delegate;
4631 }
4632
4633 start() {
4634 if (!this.#started) {
4635 this.#started = true;
4636 addEventListener("turbo:before-fetch-response", this.inspectFetchResponse, false);
4637 }
4638 }
4639
4640 stop() {
4641 if (this.#started) {
4642 this.#started = false;
4643 removeEventListener("turbo:before-fetch-response", this.inspectFetchResponse, false);
4644 }
4645 }
4646
4647 connectStreamSource(source) {
4648 if (!this.streamSourceIsConnected(source)) {
4649 this.sources.add(source);
4650 source.addEventListener("message", this.receiveMessageEvent, false);
4651 }
4652 }
4653
4654 disconnectStreamSource(source) {
4655 if (this.streamSourceIsConnected(source)) {
4656 this.sources.delete(source);
4657 source.removeEventListener("message", this.receiveMessageEvent, false);
4658 }
4659 }
4660
4661 streamSourceIsConnected(source) {
4662 return this.sources.has(source)
4663 }
4664
4665 inspectFetchResponse = (event) => {
4666 const response = fetchResponseFromEvent(event);
4667 if (response && fetchResponseIsStream(response)) {
4668 event.preventDefault();
4669 this.receiveMessageResponse(response);
4670 }
4671 }
4672
4673 receiveMessageEvent = (event) => {
4674 if (this.#started && typeof event.data == "string") {
4675 this.receiveMessageHTML(event.data);
4676 }
4677 }
4678
4679 async receiveMessageResponse(response) {
4680 const html = await response.responseHTML;
4681 if (html) {
4682 this.receiveMessageHTML(html);
4683 }
4684 }
4685
4686 receiveMessageHTML(html) {
4687 this.delegate.receivedMessageFromStream(StreamMessage.wrap(html));
4688 }
4689 }
4690
4691 function fetchResponseFromEvent(event) {
4692 const fetchResponse = event.detail?.fetchResponse;
4693 if (fetchResponse instanceof FetchResponse) {
4694 return fetchResponse
4695 }
4696 }
4697
4698 function fetchResponseIsStream(response) {
4699 const contentType = response.contentType ?? "";
4700 return contentType.startsWith(StreamMessage.contentType)
4701 }
4702
4703 class ErrorRenderer extends Renderer {
4704 static renderElement(currentElement, newElement) {
4705 const { documentElement, body } = document;
4706
4707 documentElement.replaceChild(newElement, body);
4708 }
4709
4710 async render() {
4711 this.replaceHeadAndBody();
4712 this.activateScriptElements();
4713 }
4714
4715 replaceHeadAndBody() {
4716 const { documentElement, head } = document;
4717 documentElement.replaceChild(this.newHead, head);
4718 this.renderElement(this.currentElement, this.newElement);
4719 }
4720
4721 activateScriptElements() {
4722 for (const replaceableElement of this.scriptElements) {
4723 const parentNode = replaceableElement.parentNode;
4724 if (parentNode) {
4725 const element = activateScriptElement(replaceableElement);
4726 parentNode.replaceChild(element, replaceableElement);
4727 }
4728 }
4729 }
4730
4731 get newHead() {
4732 return this.newSnapshot.headSnapshot.element
4733 }
4734
4735 get scriptElements() {
4736 return document.documentElement.querySelectorAll("script")
4737 }
4738 }
4739
4740 class PageRenderer extends Renderer {
4741 static renderElement(currentElement, newElement) {
4742 if (document.body && newElement instanceof HTMLBodyElement) {
4743 document.body.replaceWith(newElement);
4744 } else {
4745 document.documentElement.appendChild(newElement);
4746 }
4747 }
4748
4749 get shouldRender() {
4750 return this.newSnapshot.isVisitable && this.trackedElementsAreIdentical
4751 }
4752
4753 get reloadReason() {
4754 if (!this.newSnapshot.isVisitable) {
4755 return {
4756 reason: "turbo_visit_control_is_reload"
4757 }
4758 }
4759
4760 if (!this.trackedElementsAreIdentical) {
4761 return {
4762 reason: "tracked_element_mismatch"
4763 }
4764 }
4765 }
4766
4767 async prepareToRender() {
4768 this.#setLanguage();
4769 await this.mergeHead();
4770 }
4771
4772 async render() {
4773 if (this.willRender) {
4774 await this.replaceBody();
4775 }
4776 }
4777
4778 finishRendering() {
4779 super.finishRendering();
4780 if (!this.isPreview) {
4781 this.focusFirstAutofocusableElement();
4782 }
4783 }
4784
4785 get currentHeadSnapshot() {
4786 return this.currentSnapshot.headSnapshot
4787 }
4788
4789 get newHeadSnapshot() {
4790 return this.newSnapshot.headSnapshot
4791 }
4792
4793 get newElement() {
4794 return this.newSnapshot.element
4795 }
4796
4797 #setLanguage() {
4798 const { documentElement } = this.currentSnapshot;
4799 const { lang } = this.newSnapshot;
4800
4801 if (lang) {
4802 documentElement.setAttribute("lang", lang);
4803 } else {
4804 documentElement.removeAttribute("lang");
4805 }
4806 }
4807
4808 async mergeHead() {
4809 const mergedHeadElements = this.mergeProvisionalElements();
4810 const newStylesheetElements = this.copyNewHeadStylesheetElements();
4811 this.copyNewHeadScriptElements();
4812
4813 await mergedHeadElements;
4814 await newStylesheetElements;
4815
4816 if (this.willRender) {
4817 this.removeUnusedDynamicStylesheetElements();
4818 }
4819 }
4820
4821 async replaceBody() {
4822 await this.preservingPermanentElements(async () => {
4823 this.activateNewBody();
4824 await this.assignNewBody();
4825 });
4826 }
4827
4828 get trackedElementsAreIdentical() {
4829 return this.currentHeadSnapshot.trackedElementSignature == this.newHeadSnapshot.trackedElementSignature
4830 }
4831
4832 async copyNewHeadStylesheetElements() {
4833 const loadingElements = [];
4834
4835 for (const element of this.newHeadStylesheetElements) {
4836 loadingElements.push(waitForLoad(element));
4837
4838 document.head.appendChild(element);
4839 }
4840
4841 await Promise.all(loadingElements);
4842 }
4843
4844 copyNewHeadScriptElements() {
4845 for (const element of this.newHeadScriptElements) {
4846 document.head.appendChild(activateScriptElement(element));
4847 }
4848 }
4849
4850 removeUnusedDynamicStylesheetElements() {
4851 for (const element of this.unusedDynamicStylesheetElements) {
4852 document.head.removeChild(element);
4853 }
4854 }
4855
4856 async mergeProvisionalElements() {
4857 const newHeadElements = [...this.newHeadProvisionalElements];
4858
4859 for (const element of this.currentHeadProvisionalElements) {
4860 if (!this.isCurrentElementInElementList(element, newHeadElements)) {
4861 document.head.removeChild(element);
4862 }
4863 }
4864
4865 for (const element of newHeadElements) {
4866 document.head.appendChild(element);
4867 }
4868 }
4869
4870 isCurrentElementInElementList(element, elementList) {
4871 for (const [index, newElement] of elementList.entries()) {
4872 // if title element...
4873 if (element.tagName == "TITLE") {
4874 if (newElement.tagName != "TITLE") {
4875 continue
4876 }
4877 if (element.innerHTML == newElement.innerHTML) {
4878 elementList.splice(index, 1);
4879 return true
4880 }
4881 }
4882
4883 // if any other element...
4884 if (newElement.isEqualNode(element)) {
4885 elementList.splice(index, 1);
4886 return true
4887 }
4888 }
4889
4890 return false
4891 }
4892
4893 removeCurrentHeadProvisionalElements() {
4894 for (const element of this.currentHeadProvisionalElements) {
4895 document.head.removeChild(element);
4896 }
4897 }
4898
4899 copyNewHeadProvisionalElements() {
4900 for (const element of this.newHeadProvisionalElements) {
4901 document.head.appendChild(element);
4902 }
4903 }
4904
4905 activateNewBody() {
4906 document.adoptNode(this.newElement);
4907 this.activateNewBodyScriptElements();
4908 }
4909
4910 activateNewBodyScriptElements() {
4911 for (const inertScriptElement of this.newBodyScriptElements) {
4912 const activatedScriptElement = activateScriptElement(inertScriptElement);
4913 inertScriptElement.replaceWith(activatedScriptElement);
4914 }
4915 }
4916
4917 async assignNewBody() {
4918 await this.renderElement(this.currentElement, this.newElement);
4919 }
4920
4921 get unusedDynamicStylesheetElements() {
4922 return this.oldHeadStylesheetElements.filter((element) => {
4923 return element.getAttribute("data-turbo-track") === "dynamic"
4924 })
4925 }
4926
4927 get oldHeadStylesheetElements() {
4928 return this.currentHeadSnapshot.getStylesheetElementsNotInSnapshot(this.newHeadSnapshot)
4929 }
4930
4931 get newHeadStylesheetElements() {
4932 return this.newHeadSnapshot.getStylesheetElementsNotInSnapshot(this.currentHeadSnapshot)
4933 }
4934
4935 get newHeadScriptElements() {
4936 return this.newHeadSnapshot.getScriptElementsNotInSnapshot(this.currentHeadSnapshot)
4937 }
4938
4939 get currentHeadProvisionalElements() {
4940 return this.currentHeadSnapshot.provisionalElements
4941 }
4942
4943 get newHeadProvisionalElements() {
4944 return this.newHeadSnapshot.provisionalElements
4945 }
4946
4947 get newBodyScriptElements() {
4948 return this.newElement.querySelectorAll("script")
4949 }
4950 }
4951
4952 class MorphingPageRenderer extends PageRenderer {
4953 static renderElement(currentElement, newElement) {
4954 morphElements(currentElement, newElement, {
4955 callbacks: {
4956 beforeNodeMorphed: element => !canRefreshFrame(element)
4957 }
4958 });
4959
4960 for (const frame of currentElement.querySelectorAll("turbo-frame")) {
4961 if (canRefreshFrame(frame)) frame.reload();
4962 }
4963
4964 dispatch("turbo:morph", { detail: { currentElement, newElement } });
4965 }
4966
4967 async preservingPermanentElements(callback) {
4968 return await callback()
4969 }
4970
4971 get renderMethod() {
4972 return "morph"
4973 }
4974
4975 get shouldAutofocus() {
4976 return false
4977 }
4978 }
4979
4980 function canRefreshFrame(frame) {
4981 return frame instanceof FrameElement &&
4982 frame.src &&
4983 frame.refresh === "morph" &&
4984 !frame.closest("[data-turbo-permanent]")
4985 }
4986
4987 class SnapshotCache {
4988 keys = []
4989 snapshots = {}
4990
4991 constructor(size) {
4992 this.size = size;
4993 }
4994
4995 has(location) {
4996 return toCacheKey(location) in this.snapshots
4997 }
4998
4999 get(location) {
5000 if (this.has(location)) {
5001 const snapshot = this.read(location);
5002 this.touch(location);
5003 return snapshot
5004 }
5005 }
5006
5007 put(location, snapshot) {
5008 this.write(location, snapshot);
5009 this.touch(location);
5010 return snapshot
5011 }
5012
5013 clear() {
5014 this.snapshots = {};
5015 }
5016
5017 // Private
5018
5019 read(location) {
5020 return this.snapshots[toCacheKey(location)]
5021 }
5022
5023 write(location, snapshot) {
5024 this.snapshots[toCacheKey(location)] = snapshot;
5025 }
5026
5027 touch(location) {
5028 const key = toCacheKey(location);
5029 const index = this.keys.indexOf(key);
5030 if (index > -1) this.keys.splice(index, 1);
5031 this.keys.unshift(key);
5032 this.trim();
5033 }
5034
5035 trim() {
5036 for (const key of this.keys.splice(this.size)) {
5037 delete this.snapshots[key];
5038 }
5039 }
5040 }
5041
5042 class PageView extends View {
5043 snapshotCache = new SnapshotCache(10)
5044 lastRenderedLocation = new URL(location.href)
5045 forceReloaded = false
5046
5047 shouldTransitionTo(newSnapshot) {
5048 return this.snapshot.prefersViewTransitions && newSnapshot.prefersViewTransitions
5049 }
5050
5051 renderPage(snapshot, isPreview = false, willRender = true, visit) {
5052 const shouldMorphPage = this.isPageRefresh(visit) && this.snapshot.shouldMorphPage;
5053 const rendererClass = shouldMorphPage ? MorphingPageRenderer : PageRenderer;
5054
5055 const renderer = new rendererClass(this.snapshot, snapshot, isPreview, willRender);
5056
5057 if (!renderer.shouldRender) {
5058 this.forceReloaded = true;
5059 } else {
5060 visit?.changeHistory();
5061 }
5062
5063 return this.render(renderer)
5064 }
5065
5066 renderError(snapshot, visit) {
5067 visit?.changeHistory();
5068 const renderer = new ErrorRenderer(this.snapshot, snapshot, false);
5069 return this.render(renderer)
5070 }
5071
5072 clearSnapshotCache() {
5073 this.snapshotCache.clear();
5074 }
5075
5076 async cacheSnapshot(snapshot = this.snapshot) {
5077 if (snapshot.isCacheable) {
5078 this.delegate.viewWillCacheSnapshot();
5079 const { lastRenderedLocation: location } = this;
5080 await nextEventLoopTick();
5081 const cachedSnapshot = snapshot.clone();
5082 this.snapshotCache.put(location, cachedSnapshot);
5083 return cachedSnapshot
5084 }
5085 }
5086
5087 getCachedSnapshotForLocation(location) {
5088 return this.snapshotCache.get(location)
5089 }
5090
5091 isPageRefresh(visit) {
5092 return !visit || (this.lastRenderedLocation.pathname === visit.location.pathname && visit.action === "replace")
5093 }
5094
5095 shouldPreserveScrollPosition(visit) {
5096 return this.isPageRefresh(visit) && this.snapshot.shouldPreserveScrollPosition
5097 }
5098
5099 get snapshot() {
5100 return PageSnapshot.fromElement(this.element)
5101 }
5102 }
5103
5104 class Preloader {
5105 selector = "a[data-turbo-preload]"
5106
5107 constructor(delegate, snapshotCache) {
5108 this.delegate = delegate;
5109 this.snapshotCache = snapshotCache;
5110 }
5111
5112 start() {
5113 if (document.readyState === "loading") {
5114 document.addEventListener("DOMContentLoaded", this.#preloadAll);
5115 } else {
5116 this.preloadOnLoadLinksForView(document.body);
5117 }
5118 }
5119
5120 stop() {
5121 document.removeEventListener("DOMContentLoaded", this.#preloadAll);
5122 }
5123
5124 preloadOnLoadLinksForView(element) {
5125 for (const link of element.querySelectorAll(this.selector)) {
5126 if (this.delegate.shouldPreloadLink(link)) {
5127 this.preloadURL(link);
5128 }
5129 }
5130 }
5131
5132 async preloadURL(link) {
5133 const location = new URL(link.href);
5134
5135 if (this.snapshotCache.has(location)) {
5136 return
5137 }
5138
5139 const fetchRequest = new FetchRequest(this, FetchMethod.get, location, new URLSearchParams(), link);
5140 await fetchRequest.perform();
5141 }
5142
5143 // Fetch request delegate
5144
5145 prepareRequest(fetchRequest) {
5146 fetchRequest.headers["X-Sec-Purpose"] = "prefetch";
5147 }
5148
5149 async requestSucceededWithResponse(fetchRequest, fetchResponse) {
5150 try {
5151 const responseHTML = await fetchResponse.responseHTML;
5152 const snapshot = PageSnapshot.fromHTMLString(responseHTML);
5153
5154 this.snapshotCache.put(fetchRequest.url, snapshot);
5155 } catch (_) {
5156 // If we cannot preload that is ok!
5157 }
5158 }
5159
5160 requestStarted(fetchRequest) {}
5161
5162 requestErrored(fetchRequest) {}
5163
5164 requestFinished(fetchRequest) {}
5165
5166 requestPreventedHandlingResponse(fetchRequest, fetchResponse) {}
5167
5168 requestFailedWithResponse(fetchRequest, fetchResponse) {}
5169
5170 #preloadAll = () => {
5171 this.preloadOnLoadLinksForView(document.body);
5172 }
5173 }
5174
5175 class Cache {
5176 constructor(session) {
5177 this.session = session;
5178 }
5179
5180 clear() {
5181 this.session.clearCache();
5182 }
5183
5184 resetCacheControl() {
5185 this.#setCacheControl("");
5186 }
5187
5188 exemptPageFromCache() {
5189 this.#setCacheControl("no-cache");
5190 }
5191
5192 exemptPageFromPreview() {
5193 this.#setCacheControl("no-preview");
5194 }
5195
5196 #setCacheControl(value) {
5197 setMetaContent("turbo-cache-control", value);
5198 }
5199 }
5200
5201 class Session {
5202 navigator = new Navigator(this)
5203 history = new History(this)
5204 view = new PageView(this, document.documentElement)
5205 adapter = new BrowserAdapter(this)
5206
5207 pageObserver = new PageObserver(this)
5208 cacheObserver = new CacheObserver()
5209 linkPrefetchObserver = new LinkPrefetchObserver(this, document)
5210 linkClickObserver = new LinkClickObserver(this, window)
5211 formSubmitObserver = new FormSubmitObserver(this, document)
5212 scrollObserver = new ScrollObserver(this)
5213 streamObserver = new StreamObserver(this)
5214 formLinkClickObserver = new FormLinkClickObserver(this, document.documentElement)
5215 frameRedirector = new FrameRedirector(this, document.documentElement)
5216 streamMessageRenderer = new StreamMessageRenderer()
5217 cache = new Cache(this)
5218
5219 enabled = true
5220 started = false
5221 #pageRefreshDebouncePeriod = 150
5222
5223 constructor(recentRequests) {
5224 this.recentRequests = recentRequests;
5225 this.preloader = new Preloader(this, this.view.snapshotCache);
5226 this.debouncedRefresh = this.refresh;
5227 this.pageRefreshDebouncePeriod = this.pageRefreshDebouncePeriod;
5228 }
5229
5230 start() {
5231 if (!this.started) {
5232 this.pageObserver.start();
5233 this.cacheObserver.start();
5234 this.linkPrefetchObserver.start();
5235 this.formLinkClickObserver.start();
5236 this.linkClickObserver.start();
5237 this.formSubmitObserver.start();
5238 this.scrollObserver.start();
5239 this.streamObserver.start();
5240 this.frameRedirector.start();
5241 this.history.start();
5242 this.preloader.start();
5243 this.started = true;
5244 this.enabled = true;
5245 }
5246 }
5247
5248 disable() {
5249 this.enabled = false;
5250 }
5251
5252 stop() {
5253 if (this.started) {
5254 this.pageObserver.stop();
5255 this.cacheObserver.stop();
5256 this.linkPrefetchObserver.stop();
5257 this.formLinkClickObserver.stop();
5258 this.linkClickObserver.stop();
5259 this.formSubmitObserver.stop();
5260 this.scrollObserver.stop();
5261 this.streamObserver.stop();
5262 this.frameRedirector.stop();
5263 this.history.stop();
5264 this.preloader.stop();
5265 this.started = false;
5266 }
5267 }
5268
5269 registerAdapter(adapter) {
5270 this.adapter = adapter;
5271 }
5272
5273 visit(location, options = {}) {
5274 const frameElement = options.frame ? document.getElementById(options.frame) : null;
5275
5276 if (frameElement instanceof FrameElement) {
5277 const action = options.action || getVisitAction(frameElement);
5278
5279 frameElement.delegate.proposeVisitIfNavigatedWithAction(frameElement, action);
5280 frameElement.src = location.toString();
5281 } else {
5282 this.navigator.proposeVisit(expandURL(location), options);
5283 }
5284 }
5285
5286 refresh(url, requestId) {
5287 const isRecentRequest = requestId && this.recentRequests.has(requestId);
5288 if (!isRecentRequest && !this.navigator.currentVisit) {
5289 this.visit(url, { action: "replace", shouldCacheSnapshot: false });
5290 }
5291 }
5292
5293 connectStreamSource(source) {
5294 this.streamObserver.connectStreamSource(source);
5295 }
5296
5297 disconnectStreamSource(source) {
5298 this.streamObserver.disconnectStreamSource(source);
5299 }
5300
5301 renderStreamMessage(message) {
5302 this.streamMessageRenderer.render(StreamMessage.wrap(message));
5303 }
5304
5305 clearCache() {
5306 this.view.clearSnapshotCache();
5307 }
5308
5309 setProgressBarDelay(delay) {
5310 console.warn(
5311 "Please replace `session.setProgressBarDelay(delay)` with `session.progressBarDelay = delay`. The function is deprecated and will be removed in a future version of Turbo.`"
5312 );
5313
5314 this.progressBarDelay = delay;
5315 }
5316
5317 set progressBarDelay(delay) {
5318 config.drive.progressBarDelay = delay;
5319 }
5320
5321 get progressBarDelay() {
5322 return config.drive.progressBarDelay
5323 }
5324
5325 set drive(value) {
5326 config.drive.enabled = value;
5327 }
5328
5329 get drive() {
5330 return config.drive.enabled
5331 }
5332
5333 set formMode(value) {
5334 config.forms.mode = value;
5335 }
5336
5337 get formMode() {
5338 return config.forms.mode
5339 }
5340
5341 get location() {
5342 return this.history.location
5343 }
5344
5345 get restorationIdentifier() {
5346 return this.history.restorationIdentifier
5347 }
5348
5349 get pageRefreshDebouncePeriod() {
5350 return this.#pageRefreshDebouncePeriod
5351 }
5352
5353 set pageRefreshDebouncePeriod(value) {
5354 this.refresh = debounce(this.debouncedRefresh.bind(this), value);
5355 this.#pageRefreshDebouncePeriod = value;
5356 }
5357
5358 // Preloader delegate
5359
5360 shouldPreloadLink(element) {
5361 const isUnsafe = element.hasAttribute("data-turbo-method");
5362 const isStream = element.hasAttribute("data-turbo-stream");
5363 const frameTarget = element.getAttribute("data-turbo-frame");
5364 const frame = frameTarget == "_top" ?
5365 null :
5366 document.getElementById(frameTarget) || findClosestRecursively(element, "turbo-frame:not([disabled])");
5367
5368 if (isUnsafe || isStream || frame instanceof FrameElement) {
5369 return false
5370 } else {
5371 const location = new URL(element.href);
5372
5373 return this.elementIsNavigatable(element) && locationIsVisitable(location, this.snapshot.rootLocation)
5374 }
5375 }
5376
5377 // History delegate
5378
5379 historyPoppedToLocationWithRestorationIdentifierAndDirection(location, restorationIdentifier, direction) {
5380 if (this.enabled) {
5381 this.navigator.startVisit(location, restorationIdentifier, {
5382 action: "restore",
5383 historyChanged: true,
5384 direction
5385 });
5386 } else {
5387 this.adapter.pageInvalidated({
5388 reason: "turbo_disabled"
5389 });
5390 }
5391 }
5392
5393 // Scroll observer delegate
5394
5395 scrollPositionChanged(position) {
5396 this.history.updateRestorationData({ scrollPosition: position });
5397 }
5398
5399 // Form click observer delegate
5400
5401 willSubmitFormLinkToLocation(link, location) {
5402 return this.elementIsNavigatable(link) && locationIsVisitable(location, this.snapshot.rootLocation)
5403 }
5404
5405 submittedFormLinkToLocation() {}
5406
5407 // Link hover observer delegate
5408
5409 canPrefetchRequestToLocation(link, location) {
5410 return (
5411 this.elementIsNavigatable(link) &&
5412 locationIsVisitable(location, this.snapshot.rootLocation)
5413 )
5414 }
5415
5416 // Link click observer delegate
5417
5418 willFollowLinkToLocation(link, location, event) {
5419 return (
5420 this.elementIsNavigatable(link) &&
5421 locationIsVisitable(location, this.snapshot.rootLocation) &&
5422 this.applicationAllowsFollowingLinkToLocation(link, location, event)
5423 )
5424 }
5425
5426 followedLinkToLocation(link, location) {
5427 const action = this.getActionForLink(link);
5428 const acceptsStreamResponse = link.hasAttribute("data-turbo-stream");
5429
5430 this.visit(location.href, { action, acceptsStreamResponse });
5431 }
5432
5433 // Navigator delegate
5434
5435 allowsVisitingLocationWithAction(location, action) {
5436 return this.locationWithActionIsSamePage(location, action) || this.applicationAllowsVisitingLocation(location)
5437 }
5438
5439 visitProposedToLocation(location, options) {
5440 extendURLWithDeprecatedProperties(location);
5441 this.adapter.visitProposedToLocation(location, options);
5442 }
5443
5444 // Visit delegate
5445
5446 visitStarted(visit) {
5447 if (!visit.acceptsStreamResponse) {
5448 markAsBusy(document.documentElement);
5449 this.view.markVisitDirection(visit.direction);
5450 }
5451 extendURLWithDeprecatedProperties(visit.location);
5452 if (!visit.silent) {
5453 this.notifyApplicationAfterVisitingLocation(visit.location, visit.action);
5454 }
5455 }
5456
5457 visitCompleted(visit) {
5458 this.view.unmarkVisitDirection();
5459 clearBusyState(document.documentElement);
5460 this.notifyApplicationAfterPageLoad(visit.getTimingMetrics());
5461 }
5462
5463 locationWithActionIsSamePage(location, action) {
5464 return this.navigator.locationWithActionIsSamePage(location, action)
5465 }
5466
5467 visitScrolledToSamePageLocation(oldURL, newURL) {
5468 this.notifyApplicationAfterVisitingSamePageLocation(oldURL, newURL);
5469 }
5470
5471 // Form submit observer delegate
5472
5473 willSubmitForm(form, submitter) {
5474 const action = getAction$1(form, submitter);
5475
5476 return (
5477 this.submissionIsNavigatable(form, submitter) &&
5478 locationIsVisitable(expandURL(action), this.snapshot.rootLocation)
5479 )
5480 }
5481
5482 formSubmitted(form, submitter) {
5483 this.navigator.submitForm(form, submitter);
5484 }
5485
5486 // Page observer delegate
5487
5488 pageBecameInteractive() {
5489 this.view.lastRenderedLocation = this.location;
5490 this.notifyApplicationAfterPageLoad();
5491 }
5492
5493 pageLoaded() {
5494 this.history.assumeControlOfScrollRestoration();
5495 }
5496
5497 pageWillUnload() {
5498 this.history.relinquishControlOfScrollRestoration();
5499 }
5500
5501 // Stream observer delegate
5502
5503 receivedMessageFromStream(message) {
5504 this.renderStreamMessage(message);
5505 }
5506
5507 // Page view delegate
5508
5509 viewWillCacheSnapshot() {
5510 if (!this.navigator.currentVisit?.silent) {
5511 this.notifyApplicationBeforeCachingSnapshot();
5512 }
5513 }
5514
5515 allowsImmediateRender({ element }, options) {
5516 const event = this.notifyApplicationBeforeRender(element, options);
5517 const {
5518 defaultPrevented,
5519 detail: { render }
5520 } = event;
5521
5522 if (this.view.renderer && render) {
5523 this.view.renderer.renderElement = render;
5524 }
5525
5526 return !defaultPrevented
5527 }
5528
5529 viewRenderedSnapshot(_snapshot, _isPreview, renderMethod) {
5530 this.view.lastRenderedLocation = this.history.location;
5531 this.notifyApplicationAfterRender(renderMethod);
5532 }
5533
5534 preloadOnLoadLinksForView(element) {
5535 this.preloader.preloadOnLoadLinksForView(element);
5536 }
5537
5538 viewInvalidated(reason) {
5539 this.adapter.pageInvalidated(reason);
5540 }
5541
5542 // Frame element
5543
5544 frameLoaded(frame) {
5545 this.notifyApplicationAfterFrameLoad(frame);
5546 }
5547
5548 frameRendered(fetchResponse, frame) {
5549 this.notifyApplicationAfterFrameRender(fetchResponse, frame);
5550 }
5551
5552 // Application events
5553
5554 applicationAllowsFollowingLinkToLocation(link, location, ev) {
5555 const event = this.notifyApplicationAfterClickingLinkToLocation(link, location, ev);
5556 return !event.defaultPrevented
5557 }
5558
5559 applicationAllowsVisitingLocation(location) {
5560 const event = this.notifyApplicationBeforeVisitingLocation(location);
5561 return !event.defaultPrevented
5562 }
5563
5564 notifyApplicationAfterClickingLinkToLocation(link, location, event) {
5565 return dispatch("turbo:click", {
5566 target: link,
5567 detail: { url: location.href, originalEvent: event },
5568 cancelable: true
5569 })
5570 }
5571
5572 notifyApplicationBeforeVisitingLocation(location) {
5573 return dispatch("turbo:before-visit", {
5574 detail: { url: location.href },
5575 cancelable: true
5576 })
5577 }
5578
5579 notifyApplicationAfterVisitingLocation(location, action) {
5580 return dispatch("turbo:visit", { detail: { url: location.href, action } })
5581 }
5582
5583 notifyApplicationBeforeCachingSnapshot() {
5584 return dispatch("turbo:before-cache")
5585 }
5586
5587 notifyApplicationBeforeRender(newBody, options) {
5588 return dispatch("turbo:before-render", {
5589 detail: { newBody, ...options },
5590 cancelable: true
5591 })
5592 }
5593
5594 notifyApplicationAfterRender(renderMethod) {
5595 return dispatch("turbo:render", { detail: { renderMethod } })
5596 }
5597
5598 notifyApplicationAfterPageLoad(timing = {}) {
5599 return dispatch("turbo:load", {
5600 detail: { url: this.location.href, timing }
5601 })
5602 }
5603
5604 notifyApplicationAfterVisitingSamePageLocation(oldURL, newURL) {
5605 dispatchEvent(
5606 new HashChangeEvent("hashchange", {
5607 oldURL: oldURL.toString(),
5608 newURL: newURL.toString()
5609 })
5610 );
5611 }
5612
5613 notifyApplicationAfterFrameLoad(frame) {
5614 return dispatch("turbo:frame-load", { target: frame })
5615 }
5616
5617 notifyApplicationAfterFrameRender(fetchResponse, frame) {
5618 return dispatch("turbo:frame-render", {
5619 detail: { fetchResponse },
5620 target: frame,
5621 cancelable: true
5622 })
5623 }
5624
5625 // Helpers
5626
5627 submissionIsNavigatable(form, submitter) {
5628 if (config.forms.mode == "off") {
5629 return false
5630 } else {
5631 const submitterIsNavigatable = submitter ? this.elementIsNavigatable(submitter) : true;
5632
5633 if (config.forms.mode == "optin") {
5634 return submitterIsNavigatable && form.closest('[data-turbo="true"]') != null
5635 } else {
5636 return submitterIsNavigatable && this.elementIsNavigatable(form)
5637 }
5638 }
5639 }
5640
5641 elementIsNavigatable(element) {
5642 const container = findClosestRecursively(element, "[data-turbo]");
5643 const withinFrame = findClosestRecursively(element, "turbo-frame");
5644
5645 // Check if Drive is enabled on the session or we're within a Frame.
5646 if (config.drive.enabled || withinFrame) {
5647 // Element is navigatable by default, unless `data-turbo="false"`.
5648 if (container) {
5649 return container.getAttribute("data-turbo") != "false"
5650 } else {
5651 return true
5652 }
5653 } else {
5654 // Element isn't navigatable by default, unless `data-turbo="true"`.
5655 if (container) {
5656 return container.getAttribute("data-turbo") == "true"
5657 } else {
5658 return false
5659 }
5660 }
5661 }
5662
5663 // Private
5664
5665 getActionForLink(link) {
5666 return getVisitAction(link) || "advance"
5667 }
5668
5669 get snapshot() {
5670 return this.view.snapshot
5671 }
5672 }
5673
5674 // Older versions of the Turbo Native adapters referenced the
5675 // `Location#absoluteURL` property in their implementations of
5676 // the `Adapter#visitProposedToLocation()` and `#visitStarted()`
5677 // methods. The Location class has since been removed in favor
5678 // of the DOM URL API, and accordingly all Adapter methods now
5679 // receive URL objects.
5680 //
5681 // We alias #absoluteURL to #toString() here to avoid crashing
5682 // older adapters which do not expect URL objects. We should
5683 // consider removing this support at some point in the future.
5684
5685 function extendURLWithDeprecatedProperties(url) {
5686 Object.defineProperties(url, deprecatedLocationPropertyDescriptors);
5687 }
5688
5689 const deprecatedLocationPropertyDescriptors = {
5690 absoluteURL: {
5691 get() {
5692 return this.toString()
5693 }
5694 }
5695 };
5696
5697 const session = new Session(recentRequests);
5698 const { cache, navigator: navigator$1 } = session;
5699
5700 /**
5701 * Starts the main session.
5702 * This initialises any necessary observers such as those to monitor
5703 * link interactions.
5704 */
5705 function start() {
5706 session.start();
5707 }
5708
5709 /**
5710 * Registers an adapter for the main session.
5711 *
5712 * @param adapter Adapter to register
5713 */
5714 function registerAdapter(adapter) {
5715 session.registerAdapter(adapter);
5716 }
5717
5718 /**
5719 * Performs an application visit to the given location.
5720 *
5721 * @param location Location to visit (a URL or path)
5722 * @param options Options to apply
5723 * @param options.action Type of history navigation to apply ("restore",
5724 * "replace" or "advance")
5725 * @param options.historyChanged Specifies whether the browser history has
5726 * already been changed for this visit or not
5727 * @param options.referrer Specifies the referrer of this visit such that
5728 * navigations to the same page will not result in a new history entry.
5729 * @param options.snapshotHTML Cached snapshot to render
5730 * @param options.response Response of the specified location
5731 */
5732 function visit(location, options) {
5733 session.visit(location, options);
5734 }
5735
5736 /**
5737 * Connects a stream source to the main session.
5738 *
5739 * @param source Stream source to connect
5740 */
5741 function connectStreamSource(source) {
5742 session.connectStreamSource(source);
5743 }
5744
5745 /**
5746 * Disconnects a stream source from the main session.
5747 *
5748 * @param source Stream source to disconnect
5749 */
5750 function disconnectStreamSource(source) {
5751 session.disconnectStreamSource(source);
5752 }
5753
5754 /**
5755 * Renders a stream message to the main session by appending it to the
5756 * current document.
5757 *
5758 * @param message Message to render
5759 */
5760 function renderStreamMessage(message) {
5761 session.renderStreamMessage(message);
5762 }
5763
5764 /**
5765 * Removes all entries from the Turbo Drive page cache.
5766 * Call this when state has changed on the server that may affect cached pages.
5767 *
5768 * @deprecated since version 7.2.0 in favor of `Turbo.cache.clear()`
5769 */
5770 function clearCache() {
5771 console.warn(
5772 "Please replace `Turbo.clearCache()` with `Turbo.cache.clear()`. The top-level function is deprecated and will be removed in a future version of Turbo.`"
5773 );
5774 session.clearCache();
5775 }
5776
5777 /**
5778 * Sets the delay after which the progress bar will appear during navigation.
5779 *
5780 * The progress bar appears after 500ms by default.
5781 *
5782 * Note that this method has no effect when used with the iOS or Android
5783 * adapters.
5784 *
5785 * @param delay Time to delay in milliseconds
5786 */
5787 function setProgressBarDelay(delay) {
5788 console.warn(
5789 "Please replace `Turbo.setProgressBarDelay(delay)` with `Turbo.config.drive.progressBarDelay = delay`. The top-level function is deprecated and will be removed in a future version of Turbo.`"
5790 );
5791 config.drive.progressBarDelay = delay;
5792 }
5793
5794 function setConfirmMethod(confirmMethod) {
5795 console.warn(
5796 "Please replace `Turbo.setConfirmMethod(confirmMethod)` with `Turbo.config.forms.confirm = confirmMethod`. The top-level function is deprecated and will be removed in a future version of Turbo.`"
5797 );
5798 config.forms.confirm = confirmMethod;
5799 }
5800
5801 function setFormMode(mode) {
5802 console.warn(
5803 "Please replace `Turbo.setFormMode(mode)` with `Turbo.config.forms.mode = mode`. The top-level function is deprecated and will be removed in a future version of Turbo.`"
5804 );
5805 config.forms.mode = mode;
5806 }
5807
5808 var Turbo = /*#__PURE__*/Object.freeze({
5809 __proto__: null,
5810 navigator: navigator$1,
5811 session: session,
5812 cache: cache,
5813 PageRenderer: PageRenderer,
5814 PageSnapshot: PageSnapshot,
5815 FrameRenderer: FrameRenderer,
5816 fetch: fetchWithTurboHeaders,
5817 config: config,
5818 start: start,
5819 registerAdapter: registerAdapter,
5820 visit: visit,
5821 connectStreamSource: connectStreamSource,
5822 disconnectStreamSource: disconnectStreamSource,
5823 renderStreamMessage: renderStreamMessage,
5824 clearCache: clearCache,
5825 setProgressBarDelay: setProgressBarDelay,
5826 setConfirmMethod: setConfirmMethod,
5827 setFormMode: setFormMode
5828 });
5829
5830 class TurboFrameMissingError extends Error {}
5831
5832 class FrameController {
5833 fetchResponseLoaded = (_fetchResponse) => Promise.resolve()
5834 #currentFetchRequest = null
5835 #resolveVisitPromise = () => {}
5836 #connected = false
5837 #hasBeenLoaded = false
5838 #ignoredAttributes = new Set()
5839 #shouldMorphFrame = false
5840 action = null
5841
5842 constructor(element) {
5843 this.element = element;
5844 this.view = new FrameView(this, this.element);
5845 this.appearanceObserver = new AppearanceObserver(this, this.element);
5846 this.formLinkClickObserver = new FormLinkClickObserver(this, this.element);
5847 this.linkInterceptor = new LinkInterceptor(this, this.element);
5848 this.restorationIdentifier = uuid();
5849 this.formSubmitObserver = new FormSubmitObserver(this, this.element);
5850 }
5851
5852 // Frame delegate
5853
5854 connect() {
5855 if (!this.#connected) {
5856 this.#connected = true;
5857 if (this.loadingStyle == FrameLoadingStyle.lazy) {
5858 this.appearanceObserver.start();
5859 } else {
5860 this.#loadSourceURL();
5861 }
5862 this.formLinkClickObserver.start();
5863 this.linkInterceptor.start();
5864 this.formSubmitObserver.start();
5865 }
5866 }
5867
5868 disconnect() {
5869 if (this.#connected) {
5870 this.#connected = false;
5871 this.appearanceObserver.stop();
5872 this.formLinkClickObserver.stop();
5873 this.linkInterceptor.stop();
5874 this.formSubmitObserver.stop();
5875 }
5876 }
5877
5878 disabledChanged() {
5879 if (this.loadingStyle == FrameLoadingStyle.eager) {
5880 this.#loadSourceURL();
5881 }
5882 }
5883
5884 sourceURLChanged() {
5885 if (this.#isIgnoringChangesTo("src")) return
5886
5887 if (this.element.isConnected) {
5888 this.complete = false;
5889 }
5890
5891 if (this.loadingStyle == FrameLoadingStyle.eager || this.#hasBeenLoaded) {
5892 this.#loadSourceURL();
5893 }
5894 }
5895
5896 sourceURLReloaded() {
5897 const { refresh, src } = this.element;
5898
5899 this.#shouldMorphFrame = src && refresh === "morph";
5900
5901 this.element.removeAttribute("complete");
5902 this.element.src = null;
5903 this.element.src = src;
5904 return this.element.loaded
5905 }
5906
5907 loadingStyleChanged() {
5908 if (this.loadingStyle == FrameLoadingStyle.lazy) {
5909 this.appearanceObserver.start();
5910 } else {
5911 this.appearanceObserver.stop();
5912 this.#loadSourceURL();
5913 }
5914 }
5915
5916 async #loadSourceURL() {
5917 if (this.enabled && this.isActive && !this.complete && this.sourceURL) {
5918 this.element.loaded = this.#visit(expandURL(this.sourceURL));
5919 this.appearanceObserver.stop();
5920 await this.element.loaded;
5921 this.#hasBeenLoaded = true;
5922 }
5923 }
5924
5925 async loadResponse(fetchResponse) {
5926 if (fetchResponse.redirected || (fetchResponse.succeeded && fetchResponse.isHTML)) {
5927 this.sourceURL = fetchResponse.response.url;
5928 }
5929
5930 try {
5931 const html = await fetchResponse.responseHTML;
5932 if (html) {
5933 const document = parseHTMLDocument(html);
5934 const pageSnapshot = PageSnapshot.fromDocument(document);
5935
5936 if (pageSnapshot.isVisitable) {
5937 await this.#loadFrameResponse(fetchResponse, document);
5938 } else {
5939 await this.#handleUnvisitableFrameResponse(fetchResponse);
5940 }
5941 }
5942 } finally {
5943 this.#shouldMorphFrame = false;
5944 this.fetchResponseLoaded = () => Promise.resolve();
5945 }
5946 }
5947
5948 // Appearance observer delegate
5949
5950 elementAppearedInViewport(element) {
5951 this.proposeVisitIfNavigatedWithAction(element, getVisitAction(element));
5952 this.#loadSourceURL();
5953 }
5954
5955 // Form link click observer delegate
5956
5957 willSubmitFormLinkToLocation(link) {
5958 return this.#shouldInterceptNavigation(link)
5959 }
5960
5961 submittedFormLinkToLocation(link, _location, form) {
5962 const frame = this.#findFrameElement(link);
5963 if (frame) form.setAttribute("data-turbo-frame", frame.id);
5964 }
5965
5966 // Link interceptor delegate
5967
5968 shouldInterceptLinkClick(element, _location, _event) {
5969 return this.#shouldInterceptNavigation(element)
5970 }
5971
5972 linkClickIntercepted(element, location) {
5973 this.#navigateFrame(element, location);
5974 }
5975
5976 // Form submit observer delegate
5977
5978 willSubmitForm(element, submitter) {
5979 return element.closest("turbo-frame") == this.element && this.#shouldInterceptNavigation(element, submitter)
5980 }
5981
5982 formSubmitted(element, submitter) {
5983 if (this.formSubmission) {
5984 this.formSubmission.stop();
5985 }
5986
5987 this.formSubmission = new FormSubmission(this, element, submitter);
5988 const { fetchRequest } = this.formSubmission;
5989 this.prepareRequest(fetchRequest);
5990 this.formSubmission.start();
5991 }
5992
5993 // Fetch request delegate
5994
5995 prepareRequest(request) {
5996 request.headers["Turbo-Frame"] = this.id;
5997
5998 if (this.currentNavigationElement?.hasAttribute("data-turbo-stream")) {
5999 request.acceptResponseType(StreamMessage.contentType);
6000 }
6001 }
6002
6003 requestStarted(_request) {
6004 markAsBusy(this.element);
6005 }
6006
6007 requestPreventedHandlingResponse(_request, _response) {
6008 this.#resolveVisitPromise();
6009 }
6010
6011 async requestSucceededWithResponse(request, response) {
6012 await this.loadResponse(response);
6013 this.#resolveVisitPromise();
6014 }
6015
6016 async requestFailedWithResponse(request, response) {
6017 await this.loadResponse(response);
6018 this.#resolveVisitPromise();
6019 }
6020
6021 requestErrored(request, error) {
6022 console.error(error);
6023 this.#resolveVisitPromise();
6024 }
6025
6026 requestFinished(_request) {
6027 clearBusyState(this.element);
6028 }
6029
6030 // Form submission delegate
6031
6032 formSubmissionStarted({ formElement }) {
6033 markAsBusy(formElement, this.#findFrameElement(formElement));
6034 }
6035
6036 formSubmissionSucceededWithResponse(formSubmission, response) {
6037 const frame = this.#findFrameElement(formSubmission.formElement, formSubmission.submitter);
6038
6039 frame.delegate.proposeVisitIfNavigatedWithAction(frame, getVisitAction(formSubmission.submitter, formSubmission.formElement, frame));
6040 frame.delegate.loadResponse(response);
6041
6042 if (!formSubmission.isSafe) {
6043 session.clearCache();
6044 }
6045 }
6046
6047 formSubmissionFailedWithResponse(formSubmission, fetchResponse) {
6048 this.element.delegate.loadResponse(fetchResponse);
6049 session.clearCache();
6050 }
6051
6052 formSubmissionErrored(formSubmission, error) {
6053 console.error(error);
6054 }
6055
6056 formSubmissionFinished({ formElement }) {
6057 clearBusyState(formElement, this.#findFrameElement(formElement));
6058 }
6059
6060 // View delegate
6061
6062 allowsImmediateRender({ element: newFrame }, options) {
6063 const event = dispatch("turbo:before-frame-render", {
6064 target: this.element,
6065 detail: { newFrame, ...options },
6066 cancelable: true
6067 });
6068
6069 const {
6070 defaultPrevented,
6071 detail: { render }
6072 } = event;
6073
6074 if (this.view.renderer && render) {
6075 this.view.renderer.renderElement = render;
6076 }
6077
6078 return !defaultPrevented
6079 }
6080
6081 viewRenderedSnapshot(_snapshot, _isPreview, _renderMethod) {}
6082
6083 preloadOnLoadLinksForView(element) {
6084 session.preloadOnLoadLinksForView(element);
6085 }
6086
6087 viewInvalidated() {}
6088
6089 // Frame renderer delegate
6090
6091 willRenderFrame(currentElement, _newElement) {
6092 this.previousFrameElement = currentElement.cloneNode(true);
6093 }
6094
6095 visitCachedSnapshot = ({ element }) => {
6096 const frame = element.querySelector("#" + this.element.id);
6097
6098 if (frame && this.previousFrameElement) {
6099 frame.replaceChildren(...this.previousFrameElement.children);
6100 }
6101
6102 delete this.previousFrameElement;
6103 }
6104
6105 // Private
6106
6107 async #loadFrameResponse(fetchResponse, document) {
6108 const newFrameElement = await this.extractForeignFrameElement(document.body);
6109 const rendererClass = this.#shouldMorphFrame ? MorphingFrameRenderer : FrameRenderer;
6110
6111 if (newFrameElement) {
6112 const snapshot = new Snapshot(newFrameElement);
6113 const renderer = new rendererClass(this, this.view.snapshot, snapshot, false, false);
6114 if (this.view.renderPromise) await this.view.renderPromise;
6115 this.changeHistory();
6116
6117 await this.view.render(renderer);
6118 this.complete = true;
6119 session.frameRendered(fetchResponse, this.element);
6120 session.frameLoaded(this.element);
6121 await this.fetchResponseLoaded(fetchResponse);
6122 } else if (this.#willHandleFrameMissingFromResponse(fetchResponse)) {
6123 this.#handleFrameMissingFromResponse(fetchResponse);
6124 }
6125 }
6126
6127 async #visit(url) {
6128 const request = new FetchRequest(this, FetchMethod.get, url, new URLSearchParams(), this.element);
6129
6130 this.#currentFetchRequest?.cancel();
6131 this.#currentFetchRequest = request;
6132
6133 return new Promise((resolve) => {
6134 this.#resolveVisitPromise = () => {
6135 this.#resolveVisitPromise = () => {};
6136 this.#currentFetchRequest = null;
6137 resolve();
6138 };
6139 request.perform();
6140 })
6141 }
6142
6143 #navigateFrame(element, url, submitter) {
6144 const frame = this.#findFrameElement(element, submitter);
6145
6146 frame.delegate.proposeVisitIfNavigatedWithAction(frame, getVisitAction(submitter, element, frame));
6147
6148 this.#withCurrentNavigationElement(element, () => {
6149 frame.src = url;
6150 });
6151 }
6152
6153 proposeVisitIfNavigatedWithAction(frame, action = null) {
6154 this.action = action;
6155
6156 if (this.action) {
6157 const pageSnapshot = PageSnapshot.fromElement(frame).clone();
6158 const { visitCachedSnapshot } = frame.delegate;
6159
6160 frame.delegate.fetchResponseLoaded = async (fetchResponse) => {
6161 if (frame.src) {
6162 const { statusCode, redirected } = fetchResponse;
6163 const responseHTML = await fetchResponse.responseHTML;
6164 const response = { statusCode, redirected, responseHTML };
6165 const options = {
6166 response,
6167 visitCachedSnapshot,
6168 willRender: false,
6169 updateHistory: false,
6170 restorationIdentifier: this.restorationIdentifier,
6171 snapshot: pageSnapshot
6172 };
6173
6174 if (this.action) options.action = this.action;
6175
6176 session.visit(frame.src, options);
6177 }
6178 };
6179 }
6180 }
6181
6182 changeHistory() {
6183 if (this.action) {
6184 const method = getHistoryMethodForAction(this.action);
6185 session.history.update(method, expandURL(this.element.src || ""), this.restorationIdentifier);
6186 }
6187 }
6188
6189 async #handleUnvisitableFrameResponse(fetchResponse) {
6190 console.warn(
6191 `The response (${fetchResponse.statusCode}) from <turbo-frame id="${this.element.id}"> is performing a full page visit due to turbo-visit-control.`
6192 );
6193
6194 await this.#visitResponse(fetchResponse.response);
6195 }
6196
6197 #willHandleFrameMissingFromResponse(fetchResponse) {
6198 this.element.setAttribute("complete", "");
6199
6200 const response = fetchResponse.response;
6201 const visit = async (url, options) => {
6202 if (url instanceof Response) {
6203 this.#visitResponse(url);
6204 } else {
6205 session.visit(url, options);
6206 }
6207 };
6208
6209 const event = dispatch("turbo:frame-missing", {
6210 target: this.element,
6211 detail: { response, visit },
6212 cancelable: true
6213 });
6214
6215 return !event.defaultPrevented
6216 }
6217
6218 #handleFrameMissingFromResponse(fetchResponse) {
6219 this.view.missing();
6220 this.#throwFrameMissingError(fetchResponse);
6221 }
6222
6223 #throwFrameMissingError(fetchResponse) {
6224 const message = `The response (${fetchResponse.statusCode}) did not contain the expected <turbo-frame id="${this.element.id}"> and will be ignored. To perform a full page visit instead, set turbo-visit-control to reload.`;
6225 throw new TurboFrameMissingError(message)
6226 }
6227
6228 async #visitResponse(response) {
6229 const wrapped = new FetchResponse(response);
6230 const responseHTML = await wrapped.responseHTML;
6231 const { location, redirected, statusCode } = wrapped;
6232
6233 return session.visit(location, { response: { redirected, statusCode, responseHTML } })
6234 }
6235
6236 #findFrameElement(element, submitter) {
6237 const id = getAttribute("data-turbo-frame", submitter, element) || this.element.getAttribute("target");
6238 return getFrameElementById(id) ?? this.element
6239 }
6240
6241 async extractForeignFrameElement(container) {
6242 let element;
6243 const id = CSS.escape(this.id);
6244
6245 try {
6246 element = activateElement(container.querySelector(`turbo-frame#${id}`), this.sourceURL);
6247 if (element) {
6248 return element
6249 }
6250
6251 element = activateElement(container.querySelector(`turbo-frame[src][recurse~=${id}]`), this.sourceURL);
6252 if (element) {
6253 await element.loaded;
6254 return await this.extractForeignFrameElement(element)
6255 }
6256 } catch (error) {
6257 console.error(error);
6258 return new FrameElement()
6259 }
6260
6261 return null
6262 }
6263
6264 #formActionIsVisitable(form, submitter) {
6265 const action = getAction$1(form, submitter);
6266
6267 return locationIsVisitable(expandURL(action), this.rootLocation)
6268 }
6269
6270 #shouldInterceptNavigation(element, submitter) {
6271 const id = getAttribute("data-turbo-frame", submitter, element) || this.element.getAttribute("target");
6272
6273 if (element instanceof HTMLFormElement && !this.#formActionIsVisitable(element, submitter)) {
6274 return false
6275 }
6276
6277 if (!this.enabled || id == "_top") {
6278 return false
6279 }
6280
6281 if (id) {
6282 const frameElement = getFrameElementById(id);
6283 if (frameElement) {
6284 return !frameElement.disabled
6285 }
6286 }
6287
6288 if (!session.elementIsNavigatable(element)) {
6289 return false
6290 }
6291
6292 if (submitter && !session.elementIsNavigatable(submitter)) {
6293 return false
6294 }
6295
6296 return true
6297 }
6298
6299 // Computed properties
6300
6301 get id() {
6302 return this.element.id
6303 }
6304
6305 get enabled() {
6306 return !this.element.disabled
6307 }
6308
6309 get sourceURL() {
6310 if (this.element.src) {
6311 return this.element.src
6312 }
6313 }
6314
6315 set sourceURL(sourceURL) {
6316 this.#ignoringChangesToAttribute("src", () => {
6317 this.element.src = sourceURL ?? null;
6318 });
6319 }
6320
6321 get loadingStyle() {
6322 return this.element.loading
6323 }
6324
6325 get isLoading() {
6326 return this.formSubmission !== undefined || this.#resolveVisitPromise() !== undefined
6327 }
6328
6329 get complete() {
6330 return this.element.hasAttribute("complete")
6331 }
6332
6333 set complete(value) {
6334 if (value) {
6335 this.element.setAttribute("complete", "");
6336 } else {
6337 this.element.removeAttribute("complete");
6338 }
6339 }
6340
6341 get isActive() {
6342 return this.element.isActive && this.#connected
6343 }
6344
6345 get rootLocation() {
6346 const meta = this.element.ownerDocument.querySelector(`meta[name="turbo-root"]`);
6347 const root = meta?.content ?? "/";
6348 return expandURL(root)
6349 }
6350
6351 #isIgnoringChangesTo(attributeName) {
6352 return this.#ignoredAttributes.has(attributeName)
6353 }
6354
6355 #ignoringChangesToAttribute(attributeName, callback) {
6356 this.#ignoredAttributes.add(attributeName);
6357 callback();
6358 this.#ignoredAttributes.delete(attributeName);
6359 }
6360
6361 #withCurrentNavigationElement(element, callback) {
6362 this.currentNavigationElement = element;
6363 callback();
6364 delete this.currentNavigationElement;
6365 }
6366 }
6367
6368 function getFrameElementById(id) {
6369 if (id != null) {
6370 const element = document.getElementById(id);
6371 if (element instanceof FrameElement) {
6372 return element
6373 }
6374 }
6375 }
6376
6377 function activateElement(element, currentURL) {
6378 if (element) {
6379 const src = element.getAttribute("src");
6380 if (src != null && currentURL != null && urlsAreEqual(src, currentURL)) {
6381 throw new Error(`Matching <turbo-frame id="${element.id}"> element has a source URL which references itself`)
6382 }
6383 if (element.ownerDocument !== document) {
6384 element = document.importNode(element, true);
6385 }
6386
6387 if (element instanceof FrameElement) {
6388 element.connectedCallback();
6389 element.disconnectedCallback();
6390 return element
6391 }
6392 }
6393 }
6394
6395 const StreamActions = {
6396 after() {
6397 this.targetElements.forEach((e) => e.parentElement?.insertBefore(this.templateContent, e.nextSibling));
6398 },
6399
6400 append() {
6401 this.removeDuplicateTargetChildren();
6402 this.targetElements.forEach((e) => e.append(this.templateContent));
6403 },
6404
6405 before() {
6406 this.targetElements.forEach((e) => e.parentElement?.insertBefore(this.templateContent, e));
6407 },
6408
6409 prepend() {
6410 this.removeDuplicateTargetChildren();
6411 this.targetElements.forEach((e) => e.prepend(this.templateContent));
6412 },
6413
6414 remove() {
6415 this.targetElements.forEach((e) => e.remove());
6416 },
6417
6418 replace() {
6419 const method = this.getAttribute("method");
6420
6421 this.targetElements.forEach((targetElement) => {
6422 if (method === "morph") {
6423 morphElements(targetElement, this.templateContent);
6424 } else {
6425 targetElement.replaceWith(this.templateContent);
6426 }
6427 });
6428 },
6429
6430 update() {
6431 const method = this.getAttribute("method");
6432
6433 this.targetElements.forEach((targetElement) => {
6434 if (method === "morph") {
6435 morphChildren(targetElement, this.templateContent);
6436 } else {
6437 targetElement.innerHTML = "";
6438 targetElement.append(this.templateContent);
6439 }
6440 });
6441 },
6442
6443 refresh() {
6444 session.refresh(this.baseURI, this.requestId);
6445 }
6446 };
6447
6448 // <turbo-stream action=replace target=id><template>...
6449
6450 /**
6451 * Renders updates to the page from a stream of messages.
6452 *
6453 * Using the `action` attribute, this can be configured one of eight ways:
6454 *
6455 * - `after` - inserts the result after the target
6456 * - `append` - appends the result to the target
6457 * - `before` - inserts the result before the target
6458 * - `prepend` - prepends the result to the target
6459 * - `refresh` - initiates a page refresh
6460 * - `remove` - removes the target
6461 * - `replace` - replaces the outer HTML of the target
6462 * - `update` - replaces the inner HTML of the target
6463 *
6464 * @customElement turbo-stream
6465 * @example
6466 * <turbo-stream action="append" target="dom_id">
6467 * <template>
6468 * Content to append to target designated with the dom_id.
6469 * </template>
6470 * </turbo-stream>
6471 */
6472 class StreamElement extends HTMLElement {
6473 static async renderElement(newElement) {
6474 await newElement.performAction();
6475 }
6476
6477 async connectedCallback() {
6478 try {
6479 await this.render();
6480 } catch (error) {
6481 console.error(error);
6482 } finally {
6483 this.disconnect();
6484 }
6485 }
6486
6487 async render() {
6488 return (this.renderPromise ??= (async () => {
6489 const event = this.beforeRenderEvent;
6490
6491 if (this.dispatchEvent(event)) {
6492 await nextRepaint();
6493 await event.detail.render(this);
6494 }
6495 })())
6496 }
6497
6498 disconnect() {
6499 try {
6500 this.remove();
6501 // eslint-disable-next-line no-empty
6502 } catch {}
6503 }
6504
6505 /**
6506 * Removes duplicate children (by ID)
6507 */
6508 removeDuplicateTargetChildren() {
6509 this.duplicateChildren.forEach((c) => c.remove());
6510 }
6511
6512 /**
6513 * Gets the list of duplicate children (i.e. those with the same ID)
6514 */
6515 get duplicateChildren() {
6516 const existingChildren = this.targetElements.flatMap((e) => [...e.children]).filter((c) => !!c.id);
6517 const newChildrenIds = [...(this.templateContent?.children || [])].filter((c) => !!c.id).map((c) => c.id);
6518
6519 return existingChildren.filter((c) => newChildrenIds.includes(c.id))
6520 }
6521
6522 /**
6523 * Gets the action function to be performed.
6524 */
6525 get performAction() {
6526 if (this.action) {
6527 const actionFunction = StreamActions[this.action];
6528 if (actionFunction) {
6529 return actionFunction
6530 }
6531 this.#raise("unknown action");
6532 }
6533 this.#raise("action attribute is missing");
6534 }
6535
6536 /**
6537 * Gets the target elements which the template will be rendered to.
6538 */
6539 get targetElements() {
6540 if (this.target) {
6541 return this.targetElementsById
6542 } else if (this.targets) {
6543 return this.targetElementsByQuery
6544 } else {
6545 this.#raise("target or targets attribute is missing");
6546 }
6547 }
6548
6549 /**
6550 * Gets the contents of the main `<template>`.
6551 */
6552 get templateContent() {
6553 return this.templateElement.content.cloneNode(true)
6554 }
6555
6556 /**
6557 * Gets the main `<template>` used for rendering
6558 */
6559 get templateElement() {
6560 if (this.firstElementChild === null) {
6561 const template = this.ownerDocument.createElement("template");
6562 this.appendChild(template);
6563 return template
6564 } else if (this.firstElementChild instanceof HTMLTemplateElement) {
6565 return this.firstElementChild
6566 }
6567 this.#raise("first child element must be a <template> element");
6568 }
6569
6570 /**
6571 * Gets the current action.
6572 */
6573 get action() {
6574 return this.getAttribute("action")
6575 }
6576
6577 /**
6578 * Gets the current target (an element ID) to which the result will
6579 * be rendered.
6580 */
6581 get target() {
6582 return this.getAttribute("target")
6583 }
6584
6585 /**
6586 * Gets the current "targets" selector (a CSS selector)
6587 */
6588 get targets() {
6589 return this.getAttribute("targets")
6590 }
6591
6592 /**
6593 * Reads the request-id attribute
6594 */
6595 get requestId() {
6596 return this.getAttribute("request-id")
6597 }
6598
6599 #raise(message) {
6600 throw new Error(`${this.description}: ${message}`)
6601 }
6602
6603 get description() {
6604 return (this.outerHTML.match(/<[^>]+>/) ?? [])[0] ?? "<turbo-stream>"
6605 }
6606
6607 get beforeRenderEvent() {
6608 return new CustomEvent("turbo:before-stream-render", {
6609 bubbles: true,
6610 cancelable: true,
6611 detail: { newStream: this, render: StreamElement.renderElement }
6612 })
6613 }
6614
6615 get targetElementsById() {
6616 const element = this.ownerDocument?.getElementById(this.target);
6617
6618 if (element !== null) {
6619 return [element]
6620 } else {
6621 return []
6622 }
6623 }
6624
6625 get targetElementsByQuery() {
6626 const elements = this.ownerDocument?.querySelectorAll(this.targets);
6627
6628 if (elements.length !== 0) {
6629 return Array.prototype.slice.call(elements)
6630 } else {
6631 return []
6632 }
6633 }
6634 }
6635
6636 class StreamSourceElement extends HTMLElement {
6637 streamSource = null
6638
6639 connectedCallback() {
6640 this.streamSource = this.src.match(/^ws{1,2}:/) ? new WebSocket(this.src) : new EventSource(this.src);
6641
6642 connectStreamSource(this.streamSource);
6643 }
6644
6645 disconnectedCallback() {
6646 if (this.streamSource) {
6647 this.streamSource.close();
6648
6649 disconnectStreamSource(this.streamSource);
6650 }
6651 }
6652
6653 get src() {
6654 return this.getAttribute("src") || ""
6655 }
6656 }
6657
6658 FrameElement.delegateConstructor = FrameController;
6659
6660 if (customElements.get("turbo-frame") === undefined) {
6661 customElements.define("turbo-frame", FrameElement);
6662 }
6663
6664 if (customElements.get("turbo-stream") === undefined) {
6665 customElements.define("turbo-stream", StreamElement);
6666 }
6667
6668 if (customElements.get("turbo-stream-source") === undefined) {
6669 customElements.define("turbo-stream-source", StreamSourceElement);
6670 }
6671
6672 (() => {
6673 let element = document.currentScript;
6674 if (!element) return
6675 if (element.hasAttribute("data-turbo-suppress-warning")) return
6676
6677 element = element.parentElement;
6678 while (element) {
6679 if (element == document.body) {
6680 return console.warn(
6681 unindent`
6682 You are loading Turbo from a <script> element inside the <body> element. This is probably not what you meant to do!
6683
6684 Load your application’s JavaScript bundle inside the <head> element instead. <script> elements in <body> are evaluated with each page change.
6685
6686 For more information, see: https://turbo.hotwired.dev/handbook/building#working-with-script-elements
6687
6688 ——
6689 Suppress this warning by adding a "data-turbo-suppress-warning" attribute to: %s
6690 `,
6691 element.outerHTML
6692 )
6693 }
6694
6695 element = element.parentElement;
6696 }
6697 })();
6698
6699 window.Turbo = { ...Turbo, StreamActions };
6700 start();
6701
6702 exports.FetchEnctype = FetchEnctype;
6703 exports.FetchMethod = FetchMethod;
6704 exports.FetchRequest = FetchRequest;
6705 exports.FetchResponse = FetchResponse;
6706 exports.FrameElement = FrameElement;
6707 exports.FrameLoadingStyle = FrameLoadingStyle;
6708 exports.FrameRenderer = FrameRenderer;
6709 exports.PageRenderer = PageRenderer;
6710 exports.PageSnapshot = PageSnapshot;
6711 exports.StreamActions = StreamActions;
6712 exports.StreamElement = StreamElement;
6713 exports.StreamSourceElement = StreamSourceElement;
6714 exports.cache = cache;
6715 exports.clearCache = clearCache;
6716 exports.config = config;
6717 exports.connectStreamSource = connectStreamSource;
6718 exports.disconnectStreamSource = disconnectStreamSource;
6719 exports.fetch = fetchWithTurboHeaders;
6720 exports.fetchEnctypeFromString = fetchEnctypeFromString;
6721 exports.fetchMethodFromString = fetchMethodFromString;
6722 exports.isSafe = isSafe;
6723 exports.navigator = navigator$1;
6724 exports.registerAdapter = registerAdapter;
6725 exports.renderStreamMessage = renderStreamMessage;
6726 exports.session = session;
6727 exports.setConfirmMethod = setConfirmMethod;
6728 exports.setFormMode = setFormMode;
6729 exports.setProgressBarDelay = setProgressBarDelay;
6730 exports.start = start;
6731 exports.visit = visit;
6732
6733 Object.defineProperty(exports, '__esModule', { value: true });
6734
6735}));