@hotwired/turbo
Version:
The speed of a single-page web application without having to write any JavaScript
1,821 lines (1,490 loc) • 214 kB
JavaScript
/*!
Turbo 8.0.13
Copyright © 2025 37signals LLC
*/
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
typeof define === 'function' && define.amd ? define(['exports'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.Turbo = {}));
})(this, (function (exports) { 'use strict';
/**
* The MIT License (MIT)
*
* Copyright (c) 2019 Javan Makhmali
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
(function (prototype) {
if (typeof prototype.requestSubmit == "function") return
prototype.requestSubmit = function (submitter) {
if (submitter) {
validateSubmitter(submitter, this);
submitter.click();
} else {
submitter = document.createElement("input");
submitter.type = "submit";
submitter.hidden = true;
this.appendChild(submitter);
submitter.click();
this.removeChild(submitter);
}
};
function validateSubmitter(submitter, form) {
submitter instanceof HTMLElement || raise(TypeError, "parameter 1 is not of type 'HTMLElement'");
submitter.type == "submit" || raise(TypeError, "The specified element is not a submit button");
submitter.form == form ||
raise(DOMException, "The specified element is not owned by this form element", "NotFoundError");
}
function raise(errorConstructor, message, name) {
throw new errorConstructor("Failed to execute 'requestSubmit' on 'HTMLFormElement': " + message + ".", name)
}
})(HTMLFormElement.prototype);
const submittersByForm = new WeakMap();
function findSubmitterFromClickTarget(target) {
const element = target instanceof Element ? target : target instanceof Node ? target.parentElement : null;
const candidate = element ? element.closest("input, button") : null;
return candidate?.type == "submit" ? candidate : null
}
function clickCaptured(event) {
const submitter = findSubmitterFromClickTarget(event.target);
if (submitter && submitter.form) {
submittersByForm.set(submitter.form, submitter);
}
}
(function () {
if ("submitter" in Event.prototype) return
let prototype = window.Event.prototype;
// Certain versions of Safari 15 have a bug where they won't
// populate the submitter. This hurts TurboDrive's enable/disable detection.
// See https://bugs.webkit.org/show_bug.cgi?id=229660
if ("SubmitEvent" in window) {
const prototypeOfSubmitEvent = window.SubmitEvent.prototype;
if (/Apple Computer/.test(navigator.vendor) && !("submitter" in prototypeOfSubmitEvent)) {
prototype = prototypeOfSubmitEvent;
} else {
return // polyfill not needed
}
}
addEventListener("click", clickCaptured, true);
Object.defineProperty(prototype, "submitter", {
get() {
if (this.type == "submit" && this.target instanceof HTMLFormElement) {
return submittersByForm.get(this.target)
}
}
});
})();
const FrameLoadingStyle = {
eager: "eager",
lazy: "lazy"
};
/**
* Contains a fragment of HTML which is updated based on navigation within
* it (e.g. via links or form submissions).
*
* @customElement turbo-frame
* @example
* <turbo-frame id="messages">
* <a href="/messages/expanded">
* Show all expanded messages in this frame.
* </a>
*
* <form action="/messages">
* Show response from this form within this frame.
* </form>
* </turbo-frame>
*/
class FrameElement extends HTMLElement {
static delegateConstructor = undefined
loaded = Promise.resolve()
static get observedAttributes() {
return ["disabled", "loading", "src"]
}
constructor() {
super();
this.delegate = new FrameElement.delegateConstructor(this);
}
connectedCallback() {
this.delegate.connect();
}
disconnectedCallback() {
this.delegate.disconnect();
}
reload() {
return this.delegate.sourceURLReloaded()
}
attributeChangedCallback(name) {
if (name == "loading") {
this.delegate.loadingStyleChanged();
} else if (name == "src") {
this.delegate.sourceURLChanged();
} else if (name == "disabled") {
this.delegate.disabledChanged();
}
}
/**
* Gets the URL to lazily load source HTML from
*/
get src() {
return this.getAttribute("src")
}
/**
* Sets the URL to lazily load source HTML from
*/
set src(value) {
if (value) {
this.setAttribute("src", value);
} else {
this.removeAttribute("src");
}
}
/**
* Gets the refresh mode for the frame.
*/
get refresh() {
return this.getAttribute("refresh")
}
/**
* Sets the refresh mode for the frame.
*/
set refresh(value) {
if (value) {
this.setAttribute("refresh", value);
} else {
this.removeAttribute("refresh");
}
}
get shouldReloadWithMorph() {
return this.src && this.refresh === "morph"
}
/**
* Determines if the element is loading
*/
get loading() {
return frameLoadingStyleFromString(this.getAttribute("loading") || "")
}
/**
* Sets the value of if the element is loading
*/
set loading(value) {
if (value) {
this.setAttribute("loading", value);
} else {
this.removeAttribute("loading");
}
}
/**
* Gets the disabled state of the frame.
*
* If disabled, no requests will be intercepted by the frame.
*/
get disabled() {
return this.hasAttribute("disabled")
}
/**
* Sets the disabled state of the frame.
*
* If disabled, no requests will be intercepted by the frame.
*/
set disabled(value) {
if (value) {
this.setAttribute("disabled", "");
} else {
this.removeAttribute("disabled");
}
}
/**
* Gets the autoscroll state of the frame.
*
* If true, the frame will be scrolled into view automatically on update.
*/
get autoscroll() {
return this.hasAttribute("autoscroll")
}
/**
* Sets the autoscroll state of the frame.
*
* If true, the frame will be scrolled into view automatically on update.
*/
set autoscroll(value) {
if (value) {
this.setAttribute("autoscroll", "");
} else {
this.removeAttribute("autoscroll");
}
}
/**
* Determines if the element has finished loading
*/
get complete() {
return !this.delegate.isLoading
}
/**
* Gets the active state of the frame.
*
* If inactive, source changes will not be observed.
*/
get isActive() {
return this.ownerDocument === document && !this.isPreview
}
/**
* Sets the active state of the frame.
*
* If inactive, source changes will not be observed.
*/
get isPreview() {
return this.ownerDocument?.documentElement?.hasAttribute("data-turbo-preview")
}
}
function frameLoadingStyleFromString(style) {
switch (style.toLowerCase()) {
case "lazy":
return FrameLoadingStyle.lazy
default:
return FrameLoadingStyle.eager
}
}
const drive = {
enabled: true,
progressBarDelay: 500,
unvisitableExtensions: new Set(
[
".7z", ".aac", ".apk", ".avi", ".bmp", ".bz2", ".css", ".csv", ".deb", ".dmg", ".doc",
".docx", ".exe", ".gif", ".gz", ".heic", ".heif", ".ico", ".iso", ".jpeg", ".jpg",
".js", ".json", ".m4a", ".mkv", ".mov", ".mp3", ".mp4", ".mpeg", ".mpg", ".msi",
".ogg", ".ogv", ".pdf", ".pkg", ".png", ".ppt", ".pptx", ".rar", ".rtf",
".svg", ".tar", ".tif", ".tiff", ".txt", ".wav", ".webm", ".webp", ".wma", ".wmv",
".xls", ".xlsx", ".xml", ".zip"
]
)
};
function activateScriptElement(element) {
if (element.getAttribute("data-turbo-eval") == "false") {
return element
} else {
const createdScriptElement = document.createElement("script");
const cspNonce = getCspNonce();
if (cspNonce) {
createdScriptElement.nonce = cspNonce;
}
createdScriptElement.textContent = element.textContent;
createdScriptElement.async = false;
copyElementAttributes(createdScriptElement, element);
return createdScriptElement
}
}
function copyElementAttributes(destinationElement, sourceElement) {
for (const { name, value } of sourceElement.attributes) {
destinationElement.setAttribute(name, value);
}
}
function createDocumentFragment(html) {
const template = document.createElement("template");
template.innerHTML = html;
return template.content
}
function dispatch(eventName, { target, cancelable, detail } = {}) {
const event = new CustomEvent(eventName, {
cancelable,
bubbles: true,
composed: true,
detail
});
if (target && target.isConnected) {
target.dispatchEvent(event);
} else {
document.documentElement.dispatchEvent(event);
}
return event
}
function cancelEvent(event) {
event.preventDefault();
event.stopImmediatePropagation();
}
function nextRepaint() {
if (document.visibilityState === "hidden") {
return nextEventLoopTick()
} else {
return nextAnimationFrame()
}
}
function nextAnimationFrame() {
return new Promise((resolve) => requestAnimationFrame(() => resolve()))
}
function nextEventLoopTick() {
return new Promise((resolve) => setTimeout(() => resolve(), 0))
}
function nextMicrotask() {
return Promise.resolve()
}
function parseHTMLDocument(html = "") {
return new DOMParser().parseFromString(html, "text/html")
}
function unindent(strings, ...values) {
const lines = interpolate(strings, values).replace(/^\n/, "").split("\n");
const match = lines[0].match(/^\s+/);
const indent = match ? match[0].length : 0;
return lines.map((line) => line.slice(indent)).join("\n")
}
function interpolate(strings, values) {
return strings.reduce((result, string, i) => {
const value = values[i] == undefined ? "" : values[i];
return result + string + value
}, "")
}
function uuid() {
return Array.from({ length: 36 })
.map((_, i) => {
if (i == 8 || i == 13 || i == 18 || i == 23) {
return "-"
} else if (i == 14) {
return "4"
} else if (i == 19) {
return (Math.floor(Math.random() * 4) + 8).toString(16)
} else {
return Math.floor(Math.random() * 15).toString(16)
}
})
.join("")
}
function getAttribute(attributeName, ...elements) {
for (const value of elements.map((element) => element?.getAttribute(attributeName))) {
if (typeof value == "string") return value
}
return null
}
function hasAttribute(attributeName, ...elements) {
return elements.some((element) => element && element.hasAttribute(attributeName))
}
function markAsBusy(...elements) {
for (const element of elements) {
if (element.localName == "turbo-frame") {
element.setAttribute("busy", "");
}
element.setAttribute("aria-busy", "true");
}
}
function clearBusyState(...elements) {
for (const element of elements) {
if (element.localName == "turbo-frame") {
element.removeAttribute("busy");
}
element.removeAttribute("aria-busy");
}
}
function waitForLoad(element, timeoutInMilliseconds = 2000) {
return new Promise((resolve) => {
const onComplete = () => {
element.removeEventListener("error", onComplete);
element.removeEventListener("load", onComplete);
resolve();
};
element.addEventListener("load", onComplete, { once: true });
element.addEventListener("error", onComplete, { once: true });
setTimeout(resolve, timeoutInMilliseconds);
})
}
function getHistoryMethodForAction(action) {
switch (action) {
case "replace":
return history.replaceState
case "advance":
case "restore":
return history.pushState
}
}
function isAction(action) {
return action == "advance" || action == "replace" || action == "restore"
}
function getVisitAction(...elements) {
const action = getAttribute("data-turbo-action", ...elements);
return isAction(action) ? action : null
}
function getMetaElement(name) {
return document.querySelector(`meta[name="${name}"]`)
}
function getMetaContent(name) {
const element = getMetaElement(name);
return element && element.content
}
function getCspNonce() {
const element = getMetaElement("csp-nonce");
if (element) {
const { nonce, content } = element;
return nonce == "" ? content : nonce
}
}
function setMetaContent(name, content) {
let element = getMetaElement(name);
if (!element) {
element = document.createElement("meta");
element.setAttribute("name", name);
document.head.appendChild(element);
}
element.setAttribute("content", content);
return element
}
function findClosestRecursively(element, selector) {
if (element instanceof Element) {
return (
element.closest(selector) || findClosestRecursively(element.assignedSlot || element.getRootNode()?.host, selector)
)
}
}
function elementIsFocusable(element) {
const inertDisabledOrHidden = "[inert], :disabled, [hidden], details:not([open]), dialog:not([open])";
return !!element && element.closest(inertDisabledOrHidden) == null && typeof element.focus == "function"
}
function queryAutofocusableElement(elementOrDocumentFragment) {
return Array.from(elementOrDocumentFragment.querySelectorAll("[autofocus]")).find(elementIsFocusable)
}
async function around(callback, reader) {
const before = reader();
callback();
await nextAnimationFrame();
const after = reader();
return [before, after]
}
function doesNotTargetIFrame(name) {
if (name === "_blank") {
return false
} else if (name) {
for (const element of document.getElementsByName(name)) {
if (element instanceof HTMLIFrameElement) return false
}
return true
} else {
return true
}
}
function findLinkFromClickTarget(target) {
return findClosestRecursively(target, "a[href]:not([target^=_]):not([download])")
}
function getLocationForLink(link) {
return expandURL(link.getAttribute("href") || "")
}
function debounce(fn, delay) {
let timeoutId = null;
return (...args) => {
const callback = () => fn.apply(this, args);
clearTimeout(timeoutId);
timeoutId = setTimeout(callback, delay);
}
}
const submitter = {
"aria-disabled": {
beforeSubmit: submitter => {
submitter.setAttribute("aria-disabled", "true");
submitter.addEventListener("click", cancelEvent);
},
afterSubmit: submitter => {
submitter.removeAttribute("aria-disabled");
submitter.removeEventListener("click", cancelEvent);
}
},
"disabled": {
beforeSubmit: submitter => submitter.disabled = true,
afterSubmit: submitter => submitter.disabled = false
}
};
class Config {
#submitter = null
constructor(config) {
Object.assign(this, config);
}
get submitter() {
return this.#submitter
}
set submitter(value) {
this.#submitter = submitter[value] || value;
}
}
const forms = new Config({
mode: "on",
submitter: "disabled"
});
const config = {
drive,
forms
};
function expandURL(locatable) {
return new URL(locatable.toString(), document.baseURI)
}
function getAnchor(url) {
let anchorMatch;
if (url.hash) {
return url.hash.slice(1)
// eslint-disable-next-line no-cond-assign
} else if ((anchorMatch = url.href.match(/#(.*)$/))) {
return anchorMatch[1]
}
}
function getAction$1(form, submitter) {
const action = submitter?.getAttribute("formaction") || form.getAttribute("action") || form.action;
return expandURL(action)
}
function getExtension(url) {
return (getLastPathComponent(url).match(/\.[^.]*$/) || [])[0] || ""
}
function isPrefixedBy(baseURL, url) {
const prefix = getPrefix(url);
return baseURL.href === expandURL(prefix).href || baseURL.href.startsWith(prefix)
}
function locationIsVisitable(location, rootLocation) {
return isPrefixedBy(location, rootLocation) && !config.drive.unvisitableExtensions.has(getExtension(location))
}
function getRequestURL(url) {
const anchor = getAnchor(url);
return anchor != null ? url.href.slice(0, -(anchor.length + 1)) : url.href
}
function toCacheKey(url) {
return getRequestURL(url)
}
function urlsAreEqual(left, right) {
return expandURL(left).href == expandURL(right).href
}
function getPathComponents(url) {
return url.pathname.split("/").slice(1)
}
function getLastPathComponent(url) {
return getPathComponents(url).slice(-1)[0]
}
function getPrefix(url) {
return addTrailingSlash(url.origin + url.pathname)
}
function addTrailingSlash(value) {
return value.endsWith("/") ? value : value + "/"
}
class FetchResponse {
constructor(response) {
this.response = response;
}
get succeeded() {
return this.response.ok
}
get failed() {
return !this.succeeded
}
get clientError() {
return this.statusCode >= 400 && this.statusCode <= 499
}
get serverError() {
return this.statusCode >= 500 && this.statusCode <= 599
}
get redirected() {
return this.response.redirected
}
get location() {
return expandURL(this.response.url)
}
get isHTML() {
return this.contentType && this.contentType.match(/^(?:text\/([^\s;,]+\b)?html|application\/xhtml\+xml)\b/)
}
get statusCode() {
return this.response.status
}
get contentType() {
return this.header("Content-Type")
}
get responseText() {
return this.response.clone().text()
}
get responseHTML() {
if (this.isHTML) {
return this.response.clone().text()
} else {
return Promise.resolve(undefined)
}
}
header(name) {
return this.response.headers.get(name)
}
}
class LimitedSet extends Set {
constructor(maxSize) {
super();
this.maxSize = maxSize;
}
add(value) {
if (this.size >= this.maxSize) {
const iterator = this.values();
const oldestValue = iterator.next().value;
this.delete(oldestValue);
}
super.add(value);
}
}
const recentRequests = new LimitedSet(20);
const nativeFetch = window.fetch;
function fetchWithTurboHeaders(url, options = {}) {
const modifiedHeaders = new Headers(options.headers || {});
const requestUID = uuid();
recentRequests.add(requestUID);
modifiedHeaders.append("X-Turbo-Request-Id", requestUID);
return nativeFetch(url, {
...options,
headers: modifiedHeaders
})
}
function fetchMethodFromString(method) {
switch (method.toLowerCase()) {
case "get":
return FetchMethod.get
case "post":
return FetchMethod.post
case "put":
return FetchMethod.put
case "patch":
return FetchMethod.patch
case "delete":
return FetchMethod.delete
}
}
const FetchMethod = {
get: "get",
post: "post",
put: "put",
patch: "patch",
delete: "delete"
};
function fetchEnctypeFromString(encoding) {
switch (encoding.toLowerCase()) {
case FetchEnctype.multipart:
return FetchEnctype.multipart
case FetchEnctype.plain:
return FetchEnctype.plain
default:
return FetchEnctype.urlEncoded
}
}
const FetchEnctype = {
urlEncoded: "application/x-www-form-urlencoded",
multipart: "multipart/form-data",
plain: "text/plain"
};
class FetchRequest {
abortController = new AbortController()
#resolveRequestPromise = (_value) => {}
constructor(delegate, method, location, requestBody = new URLSearchParams(), target = null, enctype = FetchEnctype.urlEncoded) {
const [url, body] = buildResourceAndBody(expandURL(location), method, requestBody, enctype);
this.delegate = delegate;
this.url = url;
this.target = target;
this.fetchOptions = {
credentials: "same-origin",
redirect: "follow",
method: method.toUpperCase(),
headers: { ...this.defaultHeaders },
body: body,
signal: this.abortSignal,
referrer: this.delegate.referrer?.href
};
this.enctype = enctype;
}
get method() {
return this.fetchOptions.method
}
set method(value) {
const fetchBody = this.isSafe ? this.url.searchParams : this.fetchOptions.body || new FormData();
const fetchMethod = fetchMethodFromString(value) || FetchMethod.get;
this.url.search = "";
const [url, body] = buildResourceAndBody(this.url, fetchMethod, fetchBody, this.enctype);
this.url = url;
this.fetchOptions.body = body;
this.fetchOptions.method = fetchMethod.toUpperCase();
}
get headers() {
return this.fetchOptions.headers
}
set headers(value) {
this.fetchOptions.headers = value;
}
get body() {
if (this.isSafe) {
return this.url.searchParams
} else {
return this.fetchOptions.body
}
}
set body(value) {
this.fetchOptions.body = value;
}
get location() {
return this.url
}
get params() {
return this.url.searchParams
}
get entries() {
return this.body ? Array.from(this.body.entries()) : []
}
cancel() {
this.abortController.abort();
}
async perform() {
const { fetchOptions } = this;
this.delegate.prepareRequest(this);
const event = await this.#allowRequestToBeIntercepted(fetchOptions);
try {
this.delegate.requestStarted(this);
if (event.detail.fetchRequest) {
this.response = event.detail.fetchRequest.response;
} else {
this.response = fetchWithTurboHeaders(this.url.href, fetchOptions);
}
const response = await this.response;
return await this.receive(response)
} catch (error) {
if (error.name !== "AbortError") {
if (this.#willDelegateErrorHandling(error)) {
this.delegate.requestErrored(this, error);
}
throw error
}
} finally {
this.delegate.requestFinished(this);
}
}
async receive(response) {
const fetchResponse = new FetchResponse(response);
const event = dispatch("turbo:before-fetch-response", {
cancelable: true,
detail: { fetchResponse },
target: this.target
});
if (event.defaultPrevented) {
this.delegate.requestPreventedHandlingResponse(this, fetchResponse);
} else if (fetchResponse.succeeded) {
this.delegate.requestSucceededWithResponse(this, fetchResponse);
} else {
this.delegate.requestFailedWithResponse(this, fetchResponse);
}
return fetchResponse
}
get defaultHeaders() {
return {
Accept: "text/html, application/xhtml+xml"
}
}
get isSafe() {
return isSafe(this.method)
}
get abortSignal() {
return this.abortController.signal
}
acceptResponseType(mimeType) {
this.headers["Accept"] = [mimeType, this.headers["Accept"]].join(", ");
}
async #allowRequestToBeIntercepted(fetchOptions) {
const requestInterception = new Promise((resolve) => (this.#resolveRequestPromise = resolve));
const event = dispatch("turbo:before-fetch-request", {
cancelable: true,
detail: {
fetchOptions,
url: this.url,
resume: this.#resolveRequestPromise
},
target: this.target
});
this.url = event.detail.url;
if (event.defaultPrevented) await requestInterception;
return event
}
#willDelegateErrorHandling(error) {
const event = dispatch("turbo:fetch-request-error", {
target: this.target,
cancelable: true,
detail: { request: this, error: error }
});
return !event.defaultPrevented
}
}
function isSafe(fetchMethod) {
return fetchMethodFromString(fetchMethod) == FetchMethod.get
}
function buildResourceAndBody(resource, method, requestBody, enctype) {
const searchParams =
Array.from(requestBody).length > 0 ? new URLSearchParams(entriesExcludingFiles(requestBody)) : resource.searchParams;
if (isSafe(method)) {
return [mergeIntoURLSearchParams(resource, searchParams), null]
} else if (enctype == FetchEnctype.urlEncoded) {
return [resource, searchParams]
} else {
return [resource, requestBody]
}
}
function entriesExcludingFiles(requestBody) {
const entries = [];
for (const [name, value] of requestBody) {
if (value instanceof File) continue
else entries.push([name, value]);
}
return entries
}
function mergeIntoURLSearchParams(url, requestBody) {
const searchParams = new URLSearchParams(entriesExcludingFiles(requestBody));
url.search = searchParams.toString();
return url
}
class AppearanceObserver {
started = false
constructor(delegate, element) {
this.delegate = delegate;
this.element = element;
this.intersectionObserver = new IntersectionObserver(this.intersect);
}
start() {
if (!this.started) {
this.started = true;
this.intersectionObserver.observe(this.element);
}
}
stop() {
if (this.started) {
this.started = false;
this.intersectionObserver.unobserve(this.element);
}
}
intersect = (entries) => {
const lastEntry = entries.slice(-1)[0];
if (lastEntry?.isIntersecting) {
this.delegate.elementAppearedInViewport(this.element);
}
}
}
class StreamMessage {
static contentType = "text/vnd.turbo-stream.html"
static wrap(message) {
if (typeof message == "string") {
return new this(createDocumentFragment(message))
} else {
return message
}
}
constructor(fragment) {
this.fragment = importStreamElements(fragment);
}
}
function importStreamElements(fragment) {
for (const element of fragment.querySelectorAll("turbo-stream")) {
const streamElement = document.importNode(element, true);
for (const inertScriptElement of streamElement.templateElement.content.querySelectorAll("script")) {
inertScriptElement.replaceWith(activateScriptElement(inertScriptElement));
}
element.replaceWith(streamElement);
}
return fragment
}
const PREFETCH_DELAY = 100;
class PrefetchCache {
#prefetchTimeout = null
#prefetched = null
get(url) {
if (this.#prefetched && this.#prefetched.url === url && this.#prefetched.expire > Date.now()) {
return this.#prefetched.request
}
}
setLater(url, request, ttl) {
this.clear();
this.#prefetchTimeout = setTimeout(() => {
request.perform();
this.set(url, request, ttl);
this.#prefetchTimeout = null;
}, PREFETCH_DELAY);
}
set(url, request, ttl) {
this.#prefetched = { url, request, expire: new Date(new Date().getTime() + ttl) };
}
clear() {
if (this.#prefetchTimeout) clearTimeout(this.#prefetchTimeout);
this.#prefetched = null;
}
}
const cacheTtl = 10 * 1000;
const prefetchCache = new PrefetchCache();
const FormSubmissionState = {
initialized: "initialized",
requesting: "requesting",
waiting: "waiting",
receiving: "receiving",
stopping: "stopping",
stopped: "stopped"
};
class FormSubmission {
state = FormSubmissionState.initialized
static confirmMethod(message) {
return Promise.resolve(confirm(message))
}
constructor(delegate, formElement, submitter, mustRedirect = false) {
const method = getMethod(formElement, submitter);
const action = getAction(getFormAction(formElement, submitter), method);
const body = buildFormData(formElement, submitter);
const enctype = getEnctype(formElement, submitter);
this.delegate = delegate;
this.formElement = formElement;
this.submitter = submitter;
this.fetchRequest = new FetchRequest(this, method, action, body, formElement, enctype);
this.mustRedirect = mustRedirect;
}
get method() {
return this.fetchRequest.method
}
set method(value) {
this.fetchRequest.method = value;
}
get action() {
return this.fetchRequest.url.toString()
}
set action(value) {
this.fetchRequest.url = expandURL(value);
}
get body() {
return this.fetchRequest.body
}
get enctype() {
return this.fetchRequest.enctype
}
get isSafe() {
return this.fetchRequest.isSafe
}
get location() {
return this.fetchRequest.url
}
// The submission process
async start() {
const { initialized, requesting } = FormSubmissionState;
const confirmationMessage = getAttribute("data-turbo-confirm", this.submitter, this.formElement);
if (typeof confirmationMessage === "string") {
const confirmMethod = typeof config.forms.confirm === "function" ?
config.forms.confirm :
FormSubmission.confirmMethod;
const answer = await confirmMethod(confirmationMessage, this.formElement, this.submitter);
if (!answer) {
return
}
}
if (this.state == initialized) {
this.state = requesting;
return this.fetchRequest.perform()
}
}
stop() {
const { stopping, stopped } = FormSubmissionState;
if (this.state != stopping && this.state != stopped) {
this.state = stopping;
this.fetchRequest.cancel();
return true
}
}
// Fetch request delegate
prepareRequest(request) {
if (!request.isSafe) {
const token = getCookieValue(getMetaContent("csrf-param")) || getMetaContent("csrf-token");
if (token) {
request.headers["X-CSRF-Token"] = token;
}
}
if (this.requestAcceptsTurboStreamResponse(request)) {
request.acceptResponseType(StreamMessage.contentType);
}
}
requestStarted(_request) {
this.state = FormSubmissionState.waiting;
if (this.submitter) config.forms.submitter.beforeSubmit(this.submitter);
this.setSubmitsWith();
markAsBusy(this.formElement);
dispatch("turbo:submit-start", {
target: this.formElement,
detail: { formSubmission: this }
});
this.delegate.formSubmissionStarted(this);
}
requestPreventedHandlingResponse(request, response) {
prefetchCache.clear();
this.result = { success: response.succeeded, fetchResponse: response };
}
requestSucceededWithResponse(request, response) {
if (response.clientError || response.serverError) {
this.delegate.formSubmissionFailedWithResponse(this, response);
return
}
prefetchCache.clear();
if (this.requestMustRedirect(request) && responseSucceededWithoutRedirect(response)) {
const error = new Error("Form responses must redirect to another location");
this.delegate.formSubmissionErrored(this, error);
} else {
this.state = FormSubmissionState.receiving;
this.result = { success: true, fetchResponse: response };
this.delegate.formSubmissionSucceededWithResponse(this, response);
}
}
requestFailedWithResponse(request, response) {
this.result = { success: false, fetchResponse: response };
this.delegate.formSubmissionFailedWithResponse(this, response);
}
requestErrored(request, error) {
this.result = { success: false, error };
this.delegate.formSubmissionErrored(this, error);
}
requestFinished(_request) {
this.state = FormSubmissionState.stopped;
if (this.submitter) config.forms.submitter.afterSubmit(this.submitter);
this.resetSubmitterText();
clearBusyState(this.formElement);
dispatch("turbo:submit-end", {
target: this.formElement,
detail: { formSubmission: this, ...this.result }
});
this.delegate.formSubmissionFinished(this);
}
// Private
setSubmitsWith() {
if (!this.submitter || !this.submitsWith) return
if (this.submitter.matches("button")) {
this.originalSubmitText = this.submitter.innerHTML;
this.submitter.innerHTML = this.submitsWith;
} else if (this.submitter.matches("input")) {
const input = this.submitter;
this.originalSubmitText = input.value;
input.value = this.submitsWith;
}
}
resetSubmitterText() {
if (!this.submitter || !this.originalSubmitText) return
if (this.submitter.matches("button")) {
this.submitter.innerHTML = this.originalSubmitText;
} else if (this.submitter.matches("input")) {
const input = this.submitter;
input.value = this.originalSubmitText;
}
}
requestMustRedirect(request) {
return !request.isSafe && this.mustRedirect
}
requestAcceptsTurboStreamResponse(request) {
return !request.isSafe || hasAttribute("data-turbo-stream", this.submitter, this.formElement)
}
get submitsWith() {
return this.submitter?.getAttribute("data-turbo-submits-with")
}
}
function buildFormData(formElement, submitter) {
const formData = new FormData(formElement);
const name = submitter?.getAttribute("name");
const value = submitter?.getAttribute("value");
if (name) {
formData.append(name, value || "");
}
return formData
}
function getCookieValue(cookieName) {
if (cookieName != null) {
const cookies = document.cookie ? document.cookie.split("; ") : [];
const cookie = cookies.find((cookie) => cookie.startsWith(cookieName));
if (cookie) {
const value = cookie.split("=").slice(1).join("=");
return value ? decodeURIComponent(value) : undefined
}
}
}
function responseSucceededWithoutRedirect(response) {
return response.statusCode == 200 && !response.redirected
}
function getFormAction(formElement, submitter) {
const formElementAction = typeof formElement.action === "string" ? formElement.action : null;
if (submitter?.hasAttribute("formaction")) {
return submitter.getAttribute("formaction") || ""
} else {
return formElement.getAttribute("action") || formElementAction || ""
}
}
function getAction(formAction, fetchMethod) {
const action = expandURL(formAction);
if (isSafe(fetchMethod)) {
action.search = "";
}
return action
}
function getMethod(formElement, submitter) {
const method = submitter?.getAttribute("formmethod") || formElement.getAttribute("method") || "";
return fetchMethodFromString(method.toLowerCase()) || FetchMethod.get
}
function getEnctype(formElement, submitter) {
return fetchEnctypeFromString(submitter?.getAttribute("formenctype") || formElement.enctype)
}
class Snapshot {
constructor(element) {
this.element = element;
}
get activeElement() {
return this.element.ownerDocument.activeElement
}
get children() {
return [...this.element.children]
}
hasAnchor(anchor) {
return this.getElementForAnchor(anchor) != null
}
getElementForAnchor(anchor) {
return anchor ? this.element.querySelector(`[id='${anchor}'], a[name='${anchor}']`) : null
}
get isConnected() {
return this.element.isConnected
}
get firstAutofocusableElement() {
return queryAutofocusableElement(this.element)
}
get permanentElements() {
return queryPermanentElementsAll(this.element)
}
getPermanentElementById(id) {
return getPermanentElementById(this.element, id)
}
getPermanentElementMapForSnapshot(snapshot) {
const permanentElementMap = {};
for (const currentPermanentElement of this.permanentElements) {
const { id } = currentPermanentElement;
const newPermanentElement = snapshot.getPermanentElementById(id);
if (newPermanentElement) {
permanentElementMap[id] = [currentPermanentElement, newPermanentElement];
}
}
return permanentElementMap
}
}
function getPermanentElementById(node, id) {
return node.querySelector(`#${id}[data-turbo-permanent]`)
}
function queryPermanentElementsAll(node) {
return node.querySelectorAll("[id][data-turbo-permanent]")
}
class FormSubmitObserver {
started = false
constructor(delegate, eventTarget) {
this.delegate = delegate;
this.eventTarget = eventTarget;
}
start() {
if (!this.started) {
this.eventTarget.addEventListener("submit", this.submitCaptured, true);
this.started = true;
}
}
stop() {
if (this.started) {
this.eventTarget.removeEventListener("submit", this.submitCaptured, true);
this.started = false;
}
}
submitCaptured = () => {
this.eventTarget.removeEventListener("submit", this.submitBubbled, false);
this.eventTarget.addEventListener("submit", this.submitBubbled, false);
}
submitBubbled = (event) => {
if (!event.defaultPrevented) {
const form = event.target instanceof HTMLFormElement ? event.target : undefined;
const submitter = event.submitter || undefined;
if (
form &&
submissionDoesNotDismissDialog(form, submitter) &&
submissionDoesNotTargetIFrame(form, submitter) &&
this.delegate.willSubmitForm(form, submitter)
) {
event.preventDefault();
event.stopImmediatePropagation();
this.delegate.formSubmitted(form, submitter);
}
}
}
}
function submissionDoesNotDismissDialog(form, submitter) {
const method = submitter?.getAttribute("formmethod") || form.getAttribute("method");
return method != "dialog"
}
function submissionDoesNotTargetIFrame(form, submitter) {
const target = submitter?.getAttribute("formtarget") || form.getAttribute("target");
return doesNotTargetIFrame(target)
}
class View {
#resolveRenderPromise = (_value) => {}
#resolveInterceptionPromise = (_value) => {}
constructor(delegate, element) {
this.delegate = delegate;
this.element = element;
}
// Scrolling
scrollToAnchor(anchor) {
const element = this.snapshot.getElementForAnchor(anchor);
if (element) {
this.scrollToElement(element);
this.focusElement(element);
} else {
this.scrollToPosition({ x: 0, y: 0 });
}
}
scrollToAnchorFromLocation(location) {
this.scrollToAnchor(getAnchor(location));
}
scrollToElement(element) {
element.scrollIntoView();
}
focusElement(element) {
if (element instanceof HTMLElement) {
if (element.hasAttribute("tabindex")) {
element.focus();
} else {
element.setAttribute("tabindex", "-1");
element.focus();
element.removeAttribute("tabindex");
}
}
}
scrollToPosition({ x, y }) {
this.scrollRoot.scrollTo(x, y);
}
scrollToTop() {
this.scrollToPosition({ x: 0, y: 0 });
}
get scrollRoot() {
return window
}
// Rendering
async render(renderer) {
const { isPreview, shouldRender, willRender, newSnapshot: snapshot } = renderer;
// A workaround to ignore tracked element mismatch reloads when performing
// a promoted Visit from a frame navigation
const shouldInvalidate = willRender;
if (shouldRender) {
try {
this.renderPromise = new Promise((resolve) => (this.#resolveRenderPromise = resolve));
this.renderer = renderer;
await this.prepareToRenderSnapshot(renderer);
const renderInterception = new Promise((resolve) => (this.#resolveInterceptionPromise = resolve));
const options = { resume: this.#resolveInterceptionPromise, render: this.renderer.renderElement, renderMethod: this.renderer.renderMethod };
const immediateRender = this.delegate.allowsImmediateRender(snapshot, options);
if (!immediateRender) await renderInterception;
await this.renderSnapshot(renderer);
this.delegate.viewRenderedSnapshot(snapshot, isPreview, this.renderer.renderMethod);
this.delegate.preloadOnLoadLinksForView(this.element);
this.finishRenderingSnapshot(renderer);
} finally {
delete this.renderer;
this.#resolveRenderPromise(undefined);
delete this.renderPromise;
}
} else if (shouldInvalidate) {
this.invalidate(renderer.reloadReason);
}
}
invalidate(reason) {
this.delegate.viewInvalidated(reason);
}
async prepareToRenderSnapshot(renderer) {
this.markAsPreview(renderer.isPreview);
await renderer.prepareToRender();
}
markAsPreview(isPreview) {
if (isPreview) {
this.element.setAttribute("data-turbo-preview", "");
} else {
this.element.removeAttribute("data-turbo-preview");
}
}
markVisitDirection(direction) {
this.element.setAttribute("data-turbo-visit-direction", direction);
}
unmarkVisitDirection() {
this.element.removeAttribute("data-turbo-visit-direction");
}
async renderSnapshot(renderer) {
await renderer.render();
}
finishRenderingSnapshot(renderer) {
renderer.finishRendering();
}
}
class FrameView extends View {
missing() {
this.element.innerHTML = `<strong class="turbo-frame-error">Content missing</strong>`;
}
get snapshot() {
return new Snapshot(this.element)
}
}
class LinkInterceptor {
constructor(delegate, element) {
this.delegate = delegate;
this.element = element;
}
start() {
this.element.addEventListener("click", this.clickBubbled);
document.addEventListener("turbo:click", this.linkClicked);
document.addEventListener("turbo:before-visit", this.willVisit);
}
stop() {
this.element.removeEventListener("click", this.clickBubbled);
document.removeEventListener("turbo:click", this.linkClicked);
document.removeEventListener("turbo:before-visit", this.willVisit);
}
clickBubbled = (event) => {
if (this.clickEventIsSignificant(event)) {
this.clickEvent = event;
} else {
delete this.clickEvent;
}
}
linkClicked = (event) => {
if (this.clickEvent && this.clickEventIsSignificant(event)) {
if (this.delegate.shouldInterceptLinkClick(event.target, event.detail.url, event.detail.originalEvent)) {
this.clickEvent.preventDefault();
event.preventDefault();
this.delegate.linkClickIntercepted(event.target, event.detail.url, event.detail.originalEvent);
}
}
delete this.clickEvent;
}
willVisit = (_event) => {
delete this.clickEvent;
}
clickEventIsSignificant(event) {
const target = event.composed ? event.target?.parentElement : event.target;
const element = findLinkFromClickTarget(target) || target;
return element instanceof Element && element.closest("turbo-frame, html") == this.element
}
}
class LinkClickObserver {
started = false
constructor(delegate, eventTarget) {
this.delegate = delegate;
this.eventTarget = eventTarget;
}
start() {
if (!this.started) {
this.eventTarget.addEventListener("click", this.clickCaptured, true);
this.started = true;
}
}
stop() {
if (this.started) {
this.eventTarget.removeEventListener("click", this.clickCaptured, true);
this.started = false;
}
}
clickCaptured = () => {
this.eventTarget.removeEventListener("click", this.clickBubbled, false);
this.eventTarget.addEventListener("click", this.clickBubbled, false);
}
clickBubbled = (event) => {
if (event instanceof MouseEvent && this.clickEventIsSignificant(event)) {
const target = (event.composedPath && event.composedPath()[0]) || event.target;
const link = findLinkFromClickTarget(target);
if (link && doesNotTargetIFrame(link.target)) {
const location = getLocationForLink(link);
if (this.delegate.willFollowLinkToLocation(link, location, event)) {
event.preventDefault();
this.delegate.followedLinkToLocation(link, location);
}
}
}
}
clickEventIsSignificant(event) {
return !(
(event.target && event.target.isContentEditable) ||
event.defaultPrevented ||
event.which > 1 ||
event.altKey ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey
)
}
}
class FormLinkClickObserver {
constructor(delegate, element) {
this.delegate = delegate;
this.linkInterceptor = new LinkClickObserver(this, element);
}
start() {
this.linkInterceptor.start();
}
stop() {
this.linkInterceptor.stop();
}
// Link hover observer delegate
canPrefetchRequestToLocation(link, location) {
return false
}
prefetchAndCacheRequestToLocation(link, location) {
return
}
// Link click observer delegate
willFollowLinkToLocation(link, location, originalEvent) {
return (
this.delegate.willSubmitFormLinkToLocation(link, location, originalEvent) &&
(link.hasAttribute("data-turbo-method") || link.hasAttribute("data-turbo-stream"))
)
}
followedLinkToLocation(link, location) {
const form = document.createElement("form");
const type = "hidden";
for (const [name, value] of location.searchParams) {
form.append(Object.assign(document.createElement("input"), { type, name, value }));
}
const action = Object.assign(location, { search: "" });
form.setAttribute("data-turbo", "true");
form.setAttribute("action", action.href);
form.setAttribute("hidden", "");
const method = link.getAttribute("data-turbo-method");
if (method) form.setAttribute("method", method);
const turboFrame = link.getAttribute("data-turbo-frame");
if (turboFrame) form.setAttribute("data-turbo-frame", turboFrame);
const turboAction = getVisitAction(link);
if (turboAction) form.setAttribute("data-turbo-action", turboAction);
const turboConfirm = link.getAttribute("data-turbo-confirm");
if (turboConfirm) form.setAttribute("data-turbo-confirm", turboConfirm);
const turboStream = link.hasAttribute("data-turbo-stream");
if (turboStream) form.setAttribute("data-turbo-stream", "");
this.delegate.submittedFormLinkToLocation(link, location, form);
document.body.appendChild(form);
form.addEventListener("turbo:submit-end", () => form.remove(), { once: true });
requestAnimationFrame(() => form.requestSubmit());
}
}
class Bardo {
static async preservingPermanentElements(delegate, permanentElementMap, callback) {
const bardo = new this(delegate, permanentElementMap);
bardo.enter();
await callback();
bardo.leave();
}
constructor(delegate, permanentElementMap) {
this.delegate = delegate;
this.permanentElementMap = permanentElementMap;
}
enter() {
for (const id in this.permanentElementMap) {
const [currentPermanentElement, newPermanentElement] = this.permanentElementMap[id];
this.delegate.enteringBardo(currentPermanentElement, newPermanentElement);
this.replaceNewPermanentElementWithPlaceholder(newPermanentElement);
}
}
leave() {
for (const id in this.permanentElementMap) {
const [currentPermanentElement] = this.permanentElementMap[id];
t