UNPKG

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