/*! Turbo 8.0.5 Copyright © 2024 37signals LLC */ /** * 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 * * * Show all expanded messages in this frame. * * *
* Show response from this form within this 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"); } } /** * 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 } } 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 isHTML(url) { return !!getExtension(url).match(/^(?:|\.(?:htm|html|xhtml|php))$/) } 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) && isHTML(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) } } function activateScriptElement(element) { if (element.getAttribute("data-turbo-eval") == "false") { return element } else { const createdScriptElement = document.createElement("script"); const cspNonce = getMetaContent("csp-nonce"); 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 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 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); } } 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, _element, _submitter) { 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 answer = await FormSubmission.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; this.submitter?.setAttribute("disabled", ""); 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; this.submitter?.removeAttribute("disabled"); 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 = `Content missing`; } 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]; this.replaceCurrentPermanentElementWithClone(currentPermanentElement); this.replacePlaceholderWithPermanentElement(currentPermanentElement); this.delegate.leavingBardo(currentPermanentElement); } } replaceNewPermanentElementWithPlaceholder(permanentElement) { const placeholder = createPlaceholderForPermanentElement(permanentElement); permanentElement.replaceWith(placeholder); } replaceCurrentPermanentElementWithClone(permanentElement) { const clone = permanentElement.cloneNode(true); permanentElement.replaceWith(clone); } replacePlaceholderWithPermanentElement(permanentElement) { const placeholder = this.getPlaceholderById(permanentElement.id); placeholder?.replaceWith(permanentElement); } getPlaceholderById(id) { return this.placeholders.find((element) => element.content == id) } get placeholders() { return [...document.querySelectorAll("meta[name=turbo-permanent-placeholder][content]")] } } function createPlaceholderForPermanentElement(permanentElement) { const element = document.createElement("meta"); element.setAttribute("name", "turbo-permanent-placeholder"); element.setAttribute("content", permanentElement.id); return element } class Renderer { #activeElement = null constructor(currentSnapshot, newSnapshot, renderElement, isPreview, willRender = true) { this.currentSnapshot = currentSnapshot; this.newSnapshot = newSnapshot; this.isPreview = isPreview; this.willRender = willRender; this.renderElement = renderElement; this.promise = new Promise((resolve, reject) => (this.resolvingFunctions = { resolve, reject })); } get shouldRender() { return true } get shouldAutofocus() { return true } get reloadReason() { return } prepareToRender() { return } render() { // Abstract method } finishRendering() { if (this.resolvingFunctions) { this.resolvingFunctions.resolve(); delete this.resolvingFunctions; } } async preservingPermanentElements(callback) { await Bardo.preservingPermanentElements(this, this.permanentElementMap, callback); } focusFirstAutofocusableElement() { if (this.shouldAutofocus) { const element = this.connectedSnapshot.firstAutofocusableElement; if (element) { element.focus(); } } } // Bardo delegate enteringBardo(currentPermanentElement) { if (this.#activeElement) return if (currentPermanentElement.contains(this.currentSnapshot.activeElement)) { this.#activeElement = this.currentSnapshot.activeElement; } } leavingBardo(currentPermanentElement) { if (currentPermanentElement.contains(this.#activeElement) && this.#activeElement instanceof HTMLElement) { this.#activeElement.focus(); this.#activeElement = null; } } get connectedSnapshot() { return this.newSnapshot.isConnected ? this.newSnapshot : this.currentSnapshot } get currentElement() { return this.currentSnapshot.element } get newElement() { return this.newSnapshot.element } get permanentElementMap() { return this.currentSnapshot.getPermanentElementMapForSnapshot(this.newSnapshot) } get renderMethod() { return "replace" } } class FrameRenderer extends Renderer { static renderElement(currentElement, newElement) { const destinationRange = document.createRange(); destinationRange.selectNodeContents(currentElement); destinationRange.deleteContents(); const frameElement = newElement; const sourceRange = frameElement.ownerDocument?.createRange(); if (sourceRange) { sourceRange.selectNodeContents(frameElement); currentElement.appendChild(sourceRange.extractContents()); } } constructor(delegate, currentSnapshot, newSnapshot, renderElement, isPreview, willRender = true) { super(currentSnapshot, newSnapshot, renderElement, isPreview, willRender); this.delegate = delegate; } get shouldRender() { return true } async render() { await nextRepaint(); this.preservingPermanentElements(() => { this.loadFrameElement(); }); this.scrollFrameIntoView(); await nextRepaint(); this.focusFirstAutofocusableElement(); await nextRepaint(); this.activateScriptElements(); } loadFrameElement() { this.delegate.willRenderFrame(this.currentElement, this.newElement); this.renderElement(this.currentElement, this.newElement); } scrollFrameIntoView() { if (this.currentElement.autoscroll || this.newElement.autoscroll) { const element = this.currentElement.firstElementChild; const block = readScrollLogicalPosition(this.currentElement.getAttribute("data-autoscroll-block"), "end"); const behavior = readScrollBehavior(this.currentElement.getAttribute("data-autoscroll-behavior"), "auto"); if (element) { element.scrollIntoView({ block, behavior }); return true } } return false } activateScriptElements() { for (const inertScriptElement of this.newScriptElements) { const activatedScriptElement = activateScriptElement(inertScriptElement); inertScriptElement.replaceWith(activatedScriptElement); } } get newScriptElements() { return this.currentElement.querySelectorAll("script") } } function readScrollLogicalPosition(value, defaultValue) { if (value == "end" || value == "start" || value == "center" || value == "nearest") { return value } else { return defaultValue } } function readScrollBehavior(value, defaultValue) { if (value == "auto" || value == "smooth") { return value } else { return defaultValue } } class ProgressBar { static animationDuration = 300 /*ms*/ static get defaultCSS() { return unindent` .turbo-progress-bar { position: fixed; display: block; top: 0; left: 0; height: 3px; background: #0076ff; z-index: 2147483647; transition: width ${ProgressBar.animationDuration}ms ease-out, opacity ${ProgressBar.animationDuration / 2}ms ${ProgressBar.animationDuration / 2}ms ease-in; transform: translate3d(0, 0, 0); } ` } hiding = false value = 0 visible = false constructor() { this.stylesheetElement = this.createStylesheetElement(); this.progressElement = this.createProgressElement(); this.installStylesheetElement(); this.setValue(0); } show() { if (!this.visible) { this.visible = true; this.installProgressElement(); this.startTrickling(); } } hide() { if (this.visible && !this.hiding) { this.hiding = true; this.fadeProgressElement(() => { this.uninstallProgressElement(); this.stopTrickling(); this.visible = false; this.hiding = false; }); } } setValue(value) { this.value = value; this.refresh(); } // Private installStylesheetElement() { document.head.insertBefore(this.stylesheetElement, document.head.firstChild); } installProgressElement() { this.progressElement.style.width = "0"; this.progressElement.style.opacity = "1"; document.documentElement.insertBefore(this.progressElement, document.body); this.refresh(); } fadeProgressElement(callback) { this.progressElement.style.opacity = "0"; setTimeout(callback, ProgressBar.animationDuration * 1.5); } uninstallProgressElement() { if (this.progressElement.parentNode) { document.documentElement.removeChild(this.progressElement); } } startTrickling() { if (!this.trickleInterval) { this.trickleInterval = window.setInterval(this.trickle, ProgressBar.animationDuration); } } stopTrickling() { window.clearInterval(this.trickleInterval); delete this.trickleInterval; } trickle = () => { this.setValue(this.value + Math.random() / 100); } refresh() { requestAnimationFrame(() => { this.progressElement.style.width = `${10 + this.value * 90}%`; }); } createStylesheetElement() { const element = document.createElement("style"); element.type = "text/css"; element.textContent = ProgressBar.defaultCSS; if (this.cspNonce) { element.nonce = this.cspNonce; } return element } createProgressElement() { const element = document.createElement("div"); element.className = "turbo-progress-bar"; return element } get cspNonce() { return getMetaContent("csp-nonce") } } class HeadSnapshot extends Snapshot { detailsByOuterHTML = this.children .filter((element) => !elementIsNoscript(element)) .map((element) => elementWithoutNonce(element)) .reduce((result, element) => { const { outerHTML } = element; const details = outerHTML in result ? result[outerHTML] : { type: elementType(element), tracked: elementIsTracked(element), elements: [] }; return { ...result, [outerHTML]: { ...details, elements: [...details.elements, element] } } }, {}) get trackedElementSignature() { return Object.keys(this.detailsByOuterHTML) .filter((outerHTML) => this.detailsByOuterHTML[outerHTML].tracked) .join("") } getScriptElementsNotInSnapshot(snapshot) { return this.getElementsMatchingTypeNotInSnapshot("script", snapshot) } getStylesheetElementsNotInSnapshot(snapshot) { return this.getElementsMatchingTypeNotInSnapshot("stylesheet", snapshot) } getElementsMatchingTypeNotInSnapshot(matchedType, snapshot) { return Object.keys(this.detailsByOuterHTML) .filter((outerHTML) => !(outerHTML in snapshot.detailsByOuterHTML)) .map((outerHTML) => this.detailsByOuterHTML[outerHTML]) .filter(({ type }) => type == matchedType) .map(({ elements: [element] }) => element) } get provisionalElements() { return Object.keys(this.detailsByOuterHTML).reduce((result, outerHTML) => { const { type, tracked, elements } = this.detailsByOuterHTML[outerHTML]; if (type == null && !tracked) { return [...result, ...elements] } else if (elements.length > 1) { return [...result, ...elements.slice(1)] } else { return result } }, []) } getMetaValue(name) { const element = this.findMetaElementByName(name); return element ? element.getAttribute("content") : null } findMetaElementByName(name) { return Object.keys(this.detailsByOuterHTML).reduce((result, outerHTML) => { const { elements: [element] } = this.detailsByOuterHTML[outerHTML]; return elementIsMetaElementWithName(element, name) ? element : result }, undefined | undefined) } } function elementType(element) { if (elementIsScript(element)) { return "script" } else if (elementIsStylesheet(element)) { return "stylesheet" } } function elementIsTracked(element) { return element.getAttribute("data-turbo-track") == "reload" } function elementIsScript(element) { const tagName = element.localName; return tagName == "script" } function elementIsNoscript(element) { const tagName = element.localName; return tagName == "noscript" } function elementIsStylesheet(element) { const tagName = element.localName; return tagName == "style" || (tagName == "link" && element.getAttribute("rel") == "stylesheet") } function elementIsMetaElementWithName(element, name) { const tagName = element.localName; return tagName == "meta" && element.getAttribute("name") == name } function elementWithoutNonce(element) { if (element.hasAttribute("nonce")) { element.setAttribute("nonce", ""); } return element } class PageSnapshot extends Snapshot { static fromHTMLString(html = "") { return this.fromDocument(parseHTMLDocument(html)) } static fromElement(element) { return this.fromDocument(element.ownerDocument) } static fromDocument({ documentElement, body, head }) { return new this(documentElement, body, new HeadSnapshot(head)) } constructor(documentElement, body, headSnapshot) { super(body); this.documentElement = documentElement; this.headSnapshot = headSnapshot; } clone() { const clonedElement = this.element.cloneNode(true); const selectElements = this.element.querySelectorAll("select"); const clonedSelectElements = clonedElement.querySelectorAll("select"); for (const [index, source] of selectElements.entries()) { const clone = clonedSelectElements[index]; for (const option of clone.selectedOptions) option.selected = false; for (const option of source.selectedOptions) clone.options[option.index].selected = true; } for (const clonedPasswordInput of clonedElement.querySelectorAll('input[type="password"]')) { clonedPasswordInput.value = ""; } return new PageSnapshot(this.documentElement, clonedElement, this.headSnapshot) } get lang() { return this.documentElement.getAttribute("lang") } get headElement() { return this.headSnapshot.element } get rootLocation() { const root = this.getSetting("root") ?? "/"; return expandURL(root) } get cacheControlValue() { return this.getSetting("cache-control") } get isPreviewable() { return this.cacheControlValue != "no-preview" } get isCacheable() { return this.cacheControlValue != "no-cache" } get isVisitable() { return this.getSetting("visit-control") != "reload" } get prefersViewTransitions() { return this.headSnapshot.getMetaValue("view-transition") === "same-origin" } get shouldMorphPage() { return this.getSetting("refresh-method") === "morph" } get shouldPreserveScrollPosition() { return this.getSetting("refresh-scroll") === "preserve" } // Private getSetting(name) { return this.headSnapshot.getMetaValue(`turbo-${name}`) } } class ViewTransitioner { #viewTransitionStarted = false #lastOperation = Promise.resolve() renderChange(useViewTransition, render) { if (useViewTransition && this.viewTransitionsAvailable && !this.#viewTransitionStarted) { this.#viewTransitionStarted = true; this.#lastOperation = this.#lastOperation.then(async () => { await document.startViewTransition(render).finished; }); } else { this.#lastOperation = this.#lastOperation.then(render); } return this.#lastOperation } get viewTransitionsAvailable() { return document.startViewTransition } } const defaultOptions = { action: "advance", historyChanged: false, visitCachedSnapshot: () => {}, willRender: true, updateHistory: true, shouldCacheSnapshot: true, acceptsStreamResponse: false }; const TimingMetric = { visitStart: "visitStart", requestStart: "requestStart", requestEnd: "requestEnd", visitEnd: "visitEnd" }; const VisitState = { initialized: "initialized", started: "started", canceled: "canceled", failed: "failed", completed: "completed" }; const SystemStatusCode = { networkFailure: 0, timeoutFailure: -1, contentTypeMismatch: -2 }; const Direction = { advance: "forward", restore: "back", replace: "none" }; class Visit { identifier = uuid() // Required by turbo-ios timingMetrics = {} followedRedirect = false historyChanged = false scrolled = false shouldCacheSnapshot = true acceptsStreamResponse = false snapshotCached = false state = VisitState.initialized viewTransitioner = new ViewTransitioner() constructor(delegate, location, restorationIdentifier, options = {}) { this.delegate = delegate; this.location = location; this.restorationIdentifier = restorationIdentifier || uuid(); const { action, historyChanged, referrer, snapshot, snapshotHTML, response, visitCachedSnapshot, willRender, updateHistory, shouldCacheSnapshot, acceptsStreamResponse, direction } = { ...defaultOptions, ...options }; this.action = action; this.historyChanged = historyChanged; this.referrer = referrer; this.snapshot = snapshot; this.snapshotHTML = snapshotHTML; this.response = response; this.isSamePage = this.delegate.locationWithActionIsSamePage(this.location, this.action); this.isPageRefresh = this.view.isPageRefresh(this); this.visitCachedSnapshot = visitCachedSnapshot; this.willRender = willRender; this.updateHistory = updateHistory; this.scrolled = !willRender; this.shouldCacheSnapshot = shouldCacheSnapshot; this.acceptsStreamResponse = acceptsStreamResponse; this.direction = direction || Direction[action]; } get adapter() { return this.delegate.adapter } get view() { return this.delegate.view } get history() { return this.delegate.history } get restorationData() { return this.history.getRestorationDataForIdentifier(this.restorationIdentifier) } get silent() { return this.isSamePage } start() { if (this.state == VisitState.initialized) { this.recordTimingMetric(TimingMetric.visitStart); this.state = VisitState.started; this.adapter.visitStarted(this); this.delegate.visitStarted(this); } } cancel() { if (this.state == VisitState.started) { if (this.request) { this.request.cancel(); } this.cancelRender(); this.state = VisitState.canceled; } } complete() { if (this.state == VisitState.started) { this.recordTimingMetric(TimingMetric.visitEnd); this.adapter.visitCompleted(this); this.state = VisitState.completed; this.followRedirect(); if (!this.followedRedirect) { this.delegate.visitCompleted(this); } } } fail() { if (this.state == VisitState.started) { this.state = VisitState.failed; this.adapter.visitFailed(this); this.delegate.visitCompleted(this); } } changeHistory() { if (!this.historyChanged && this.updateHistory) { const actionForHistory = this.location.href === this.referrer?.href ? "replace" : this.action; const method = getHistoryMethodForAction(actionForHistory); this.history.update(method, this.location, this.restorationIdentifier); this.historyChanged = true; } } issueRequest() { if (this.hasPreloadedResponse()) { this.simulateRequest(); } else if (this.shouldIssueRequest() && !this.request) { this.request = new FetchRequest(this, FetchMethod.get, this.location); this.request.perform(); } } simulateRequest() { if (this.response) { this.startRequest(); this.recordResponse(); this.finishRequest(); } } startRequest() { this.recordTimingMetric(TimingMetric.requestStart); this.adapter.visitRequestStarted(this); } recordResponse(response = this.response) { this.response = response; if (response) { const { statusCode } = response; if (isSuccessful(statusCode)) { this.adapter.visitRequestCompleted(this); } else { this.adapter.visitRequestFailedWithStatusCode(this, statusCode); } } } finishRequest() { this.recordTimingMetric(TimingMetric.requestEnd); this.adapter.visitRequestFinished(this); } loadResponse() { if (this.response) { const { statusCode, responseHTML } = this.response; this.render(async () => { if (this.shouldCacheSnapshot) this.cacheSnapshot(); if (this.view.renderPromise) await this.view.renderPromise; if (isSuccessful(statusCode) && responseHTML != null) { const snapshot = PageSnapshot.fromHTMLString(responseHTML); await this.renderPageSnapshot(snapshot, false); this.adapter.visitRendered(this); this.complete(); } else { await this.view.renderError(PageSnapshot.fromHTMLString(responseHTML), this); this.adapter.visitRendered(this); this.fail(); } }); } } getCachedSnapshot() { const snapshot = this.view.getCachedSnapshotForLocation(this.location) || this.getPreloadedSnapshot(); if (snapshot && (!getAnchor(this.location) || snapshot.hasAnchor(getAnchor(this.location)))) { if (this.action == "restore" || snapshot.isPreviewable) { return snapshot } } } getPreloadedSnapshot() { if (this.snapshotHTML) { return PageSnapshot.fromHTMLString(this.snapshotHTML) } } hasCachedSnapshot() { return this.getCachedSnapshot() != null } loadCachedSnapshot() { const snapshot = this.getCachedSnapshot(); if (snapshot) { const isPreview = this.shouldIssueRequest(); this.render(async () => { this.cacheSnapshot(); if (this.isSamePage || this.isPageRefresh) { this.adapter.visitRendered(this); } else { if (this.view.renderPromise) await this.view.renderPromise; await this.renderPageSnapshot(snapshot, isPreview); this.adapter.visitRendered(this); if (!isPreview) { this.complete(); } } }); } } followRedirect() { if (this.redirectedToLocation && !this.followedRedirect && this.response?.redirected) { this.adapter.visitProposedToLocation(this.redirectedToLocation, { action: "replace", response: this.response, shouldCacheSnapshot: false, willRender: false }); this.followedRedirect = true; } } goToSamePageAnchor() { if (this.isSamePage) { this.render(async () => { this.cacheSnapshot(); this.performScroll(); this.changeHistory(); this.adapter.visitRendered(this); }); } } // Fetch request delegate prepareRequest(request) { if (this.acceptsStreamResponse) { request.acceptResponseType(StreamMessage.contentType); } } requestStarted() { this.startRequest(); } requestPreventedHandlingResponse(_request, _response) {} async requestSucceededWithResponse(request, response) { const responseHTML = await response.responseHTML; const { redirected, statusCode } = response; if (responseHTML == undefined) { this.recordResponse({ statusCode: SystemStatusCode.contentTypeMismatch, redirected }); } else { this.redirectedToLocation = response.redirected ? response.location : undefined; this.recordResponse({ statusCode: statusCode, responseHTML, redirected }); } } async requestFailedWithResponse(request, response) { const responseHTML = await response.responseHTML; const { redirected, statusCode } = response; if (responseHTML == undefined) { this.recordResponse({ statusCode: SystemStatusCode.contentTypeMismatch, redirected }); } else { this.recordResponse({ statusCode: statusCode, responseHTML, redirected }); } } requestErrored(_request, _error) { this.recordResponse({ statusCode: SystemStatusCode.networkFailure, redirected: false }); } requestFinished() { this.finishRequest(); } // Scrolling performScroll() { if (!this.scrolled && !this.view.forceReloaded && !this.view.shouldPreserveScrollPosition(this)) { if (this.action == "restore") { this.scrollToRestoredPosition() || this.scrollToAnchor() || this.view.scrollToTop(); } else { this.scrollToAnchor() || this.view.scrollToTop(); } if (this.isSamePage) { this.delegate.visitScrolledToSamePageLocation(this.view.lastRenderedLocation, this.location); } this.scrolled = true; } } scrollToRestoredPosition() { const { scrollPosition } = this.restorationData; if (scrollPosition) { this.view.scrollToPosition(scrollPosition); return true } } scrollToAnchor() { const anchor = getAnchor(this.location); if (anchor != null) { this.view.scrollToAnchor(anchor); return true } } // Instrumentation recordTimingMetric(metric) { this.timingMetrics[metric] = new Date().getTime(); } getTimingMetrics() { return { ...this.timingMetrics } } // Private getHistoryMethodForAction(action) { switch (action) { case "replace": return history.replaceState case "advance": case "restore": return history.pushState } } hasPreloadedResponse() { return typeof this.response == "object" } shouldIssueRequest() { if (this.isSamePage) { return false } else if (this.action == "restore") { return !this.hasCachedSnapshot() } else { return this.willRender } } cacheSnapshot() { if (!this.snapshotCached) { this.view.cacheSnapshot(this.snapshot).then((snapshot) => snapshot && this.visitCachedSnapshot(snapshot)); this.snapshotCached = true; } } async render(callback) { this.cancelRender(); this.frame = await nextRepaint(); await callback(); delete this.frame; } async renderPageSnapshot(snapshot, isPreview) { await this.viewTransitioner.renderChange(this.view.shouldTransitionTo(snapshot), async () => { await this.view.renderPage(snapshot, isPreview, this.willRender, this); this.performScroll(); }); } cancelRender() { if (this.frame) { cancelAnimationFrame(this.frame); delete this.frame; } } } function isSuccessful(statusCode) { return statusCode >= 200 && statusCode < 300 } class BrowserAdapter { progressBar = new ProgressBar() constructor(session) { this.session = session; } visitProposedToLocation(location, options) { if (locationIsVisitable(location, this.navigator.rootLocation)) { this.navigator.startVisit(location, options?.restorationIdentifier || uuid(), options); } else { window.location.href = location.toString(); } } visitStarted(visit) { this.location = visit.location; visit.loadCachedSnapshot(); visit.issueRequest(); visit.goToSamePageAnchor(); } visitRequestStarted(visit) { this.progressBar.setValue(0); if (visit.hasCachedSnapshot() || visit.action != "restore") { this.showVisitProgressBarAfterDelay(); } else { this.showProgressBar(); } } visitRequestCompleted(visit) { visit.loadResponse(); } visitRequestFailedWithStatusCode(visit, statusCode) { switch (statusCode) { case SystemStatusCode.networkFailure: case SystemStatusCode.timeoutFailure: case SystemStatusCode.contentTypeMismatch: return this.reload({ reason: "request_failed", context: { statusCode } }) default: return visit.loadResponse() } } visitRequestFinished(_visit) {} visitCompleted(_visit) { this.progressBar.setValue(1); this.hideVisitProgressBar(); } pageInvalidated(reason) { this.reload(reason); } visitFailed(_visit) { this.progressBar.setValue(1); this.hideVisitProgressBar(); } visitRendered(_visit) {} // Form Submission Delegate formSubmissionStarted(_formSubmission) { this.progressBar.setValue(0); this.showFormProgressBarAfterDelay(); } formSubmissionFinished(_formSubmission) { this.progressBar.setValue(1); this.hideFormProgressBar(); } // Private showVisitProgressBarAfterDelay() { this.visitProgressBarTimeout = window.setTimeout(this.showProgressBar, this.session.progressBarDelay); } hideVisitProgressBar() { this.progressBar.hide(); if (this.visitProgressBarTimeout != null) { window.clearTimeout(this.visitProgressBarTimeout); delete this.visitProgressBarTimeout; } } showFormProgressBarAfterDelay() { if (this.formProgressBarTimeout == null) { this.formProgressBarTimeout = window.setTimeout(this.showProgressBar, this.session.progressBarDelay); } } hideFormProgressBar() { this.progressBar.hide(); if (this.formProgressBarTimeout != null) { window.clearTimeout(this.formProgressBarTimeout); delete this.formProgressBarTimeout; } } showProgressBar = () => { this.progressBar.show(); } reload(reason) { dispatch("turbo:reload", { detail: reason }); window.location.href = this.location?.toString() || window.location.href; } get navigator() { return this.session.navigator } } class CacheObserver { selector = "[data-turbo-temporary]" deprecatedSelector = "[data-turbo-cache=false]" started = false start() { if (!this.started) { this.started = true; addEventListener("turbo:before-cache", this.removeTemporaryElements, false); } } stop() { if (this.started) { this.started = false; removeEventListener("turbo:before-cache", this.removeTemporaryElements, false); } } removeTemporaryElements = (_event) => { for (const element of this.temporaryElements) { element.remove(); } } get temporaryElements() { return [...document.querySelectorAll(this.selector), ...this.temporaryElementsWithDeprecation] } get temporaryElementsWithDeprecation() { const elements = document.querySelectorAll(this.deprecatedSelector); if (elements.length) { console.warn( `The ${this.deprecatedSelector} selector is deprecated and will be removed in a future version. Use ${this.selector} instead.` ); } return [...elements] } } class FrameRedirector { constructor(session, element) { this.session = session; this.element = element; this.linkInterceptor = new LinkInterceptor(this, element); this.formSubmitObserver = new FormSubmitObserver(this, element); } start() { this.linkInterceptor.start(); this.formSubmitObserver.start(); } stop() { this.linkInterceptor.stop(); this.formSubmitObserver.stop(); } // Link interceptor delegate shouldInterceptLinkClick(element, _location, _event) { return this.#shouldRedirect(element) } linkClickIntercepted(element, url, event) { const frame = this.#findFrameElement(element); if (frame) { frame.delegate.linkClickIntercepted(element, url, event); } } // Form submit observer delegate willSubmitForm(element, submitter) { return ( element.closest("turbo-frame") == null && this.#shouldSubmit(element, submitter) && this.#shouldRedirect(element, submitter) ) } formSubmitted(element, submitter) { const frame = this.#findFrameElement(element, submitter); if (frame) { frame.delegate.formSubmitted(element, submitter); } } #shouldSubmit(form, submitter) { const action = getAction$1(form, submitter); const meta = this.element.ownerDocument.querySelector(`meta[name="turbo-root"]`); const rootLocation = expandURL(meta?.content ?? "/"); return this.#shouldRedirect(form, submitter) && locationIsVisitable(action, rootLocation) } #shouldRedirect(element, submitter) { const isNavigatable = element instanceof HTMLFormElement ? this.session.submissionIsNavigatable(element, submitter) : this.session.elementIsNavigatable(element); if (isNavigatable) { const frame = this.#findFrameElement(element, submitter); return frame ? frame != element.closest("turbo-frame") : false } else { return false } } #findFrameElement(element, submitter) { const id = submitter?.getAttribute("data-turbo-frame") || element.getAttribute("data-turbo-frame"); if (id && id != "_top") { const frame = this.element.querySelector(`#${id}:not([disabled])`); if (frame instanceof FrameElement) { return frame } } } } class History { location restorationIdentifier = uuid() restorationData = {} started = false pageLoaded = false currentIndex = 0 constructor(delegate) { this.delegate = delegate; } start() { if (!this.started) { addEventListener("popstate", this.onPopState, false); addEventListener("load", this.onPageLoad, false); this.currentIndex = history.state?.turbo?.restorationIndex || 0; this.started = true; this.replace(new URL(window.location.href)); } } stop() { if (this.started) { removeEventListener("popstate", this.onPopState, false); removeEventListener("load", this.onPageLoad, false); this.started = false; } } push(location, restorationIdentifier) { this.update(history.pushState, location, restorationIdentifier); } replace(location, restorationIdentifier) { this.update(history.replaceState, location, restorationIdentifier); } update(method, location, restorationIdentifier = uuid()) { if (method === history.pushState) ++this.currentIndex; const state = { turbo: { restorationIdentifier, restorationIndex: this.currentIndex } }; method.call(history, state, "", location.href); this.location = location; this.restorationIdentifier = restorationIdentifier; } // Restoration data getRestorationDataForIdentifier(restorationIdentifier) { return this.restorationData[restorationIdentifier] || {} } updateRestorationData(additionalData) { const { restorationIdentifier } = this; const restorationData = this.restorationData[restorationIdentifier]; this.restorationData[restorationIdentifier] = { ...restorationData, ...additionalData }; } // Scroll restoration assumeControlOfScrollRestoration() { if (!this.previousScrollRestoration) { this.previousScrollRestoration = history.scrollRestoration ?? "auto"; history.scrollRestoration = "manual"; } } relinquishControlOfScrollRestoration() { if (this.previousScrollRestoration) { history.scrollRestoration = this.previousScrollRestoration; delete this.previousScrollRestoration; } } // Event handlers onPopState = (event) => { if (this.shouldHandlePopState()) { const { turbo } = event.state || {}; if (turbo) { this.location = new URL(window.location.href); const { restorationIdentifier, restorationIndex } = turbo; this.restorationIdentifier = restorationIdentifier; const direction = restorationIndex > this.currentIndex ? "forward" : "back"; this.delegate.historyPoppedToLocationWithRestorationIdentifierAndDirection(this.location, restorationIdentifier, direction); this.currentIndex = restorationIndex; } } } onPageLoad = async (_event) => { await nextMicrotask(); this.pageLoaded = true; } // Private shouldHandlePopState() { // Safari dispatches a popstate event after window's load event, ignore it return this.pageIsLoaded() } pageIsLoaded() { return this.pageLoaded || document.readyState == "complete" } } class LinkPrefetchObserver { started = false #prefetchedLink = null constructor(delegate, eventTarget) { this.delegate = delegate; this.eventTarget = eventTarget; } start() { if (this.started) return if (this.eventTarget.readyState === "loading") { this.eventTarget.addEventListener("DOMContentLoaded", this.#enable, { once: true }); } else { this.#enable(); } } stop() { if (!this.started) return this.eventTarget.removeEventListener("mouseenter", this.#tryToPrefetchRequest, { capture: true, passive: true }); this.eventTarget.removeEventListener("mouseleave", this.#cancelRequestIfObsolete, { capture: true, passive: true }); this.eventTarget.removeEventListener("turbo:before-fetch-request", this.#tryToUsePrefetchedRequest, true); this.started = false; } #enable = () => { this.eventTarget.addEventListener("mouseenter", this.#tryToPrefetchRequest, { capture: true, passive: true }); this.eventTarget.addEventListener("mouseleave", this.#cancelRequestIfObsolete, { capture: true, passive: true }); this.eventTarget.addEventListener("turbo:before-fetch-request", this.#tryToUsePrefetchedRequest, true); this.started = true; } #tryToPrefetchRequest = (event) => { if (getMetaContent("turbo-prefetch") === "false") return const target = event.target; const isLink = target.matches && target.matches("a[href]:not([target^=_]):not([download])"); if (isLink && this.#isPrefetchable(target)) { const link = target; const location = getLocationForLink(link); if (this.delegate.canPrefetchRequestToLocation(link, location)) { this.#prefetchedLink = link; const fetchRequest = new FetchRequest( this, FetchMethod.get, location, new URLSearchParams(), target ); prefetchCache.setLater(location.toString(), fetchRequest, this.#cacheTtl); } } } #cancelRequestIfObsolete = (event) => { if (event.target === this.#prefetchedLink) this.#cancelPrefetchRequest(); } #cancelPrefetchRequest = () => { prefetchCache.clear(); this.#prefetchedLink = null; } #tryToUsePrefetchedRequest = (event) => { if (event.target.tagName !== "FORM" && event.detail.fetchOptions.method === "GET") { const cached = prefetchCache.get(event.detail.url.toString()); if (cached) { // User clicked link, use cache response event.detail.fetchRequest = cached; } prefetchCache.clear(); } } prepareRequest(request) { const link = request.target; request.headers["X-Sec-Purpose"] = "prefetch"; const turboFrame = link.closest("turbo-frame"); const turboFrameTarget = link.getAttribute("data-turbo-frame") || turboFrame?.getAttribute("target") || turboFrame?.id; if (turboFrameTarget && turboFrameTarget !== "_top") { request.headers["Turbo-Frame"] = turboFrameTarget; } } // Fetch request interface requestSucceededWithResponse() {} requestStarted(fetchRequest) {} requestErrored(fetchRequest) {} requestFinished(fetchRequest) {} requestPreventedHandlingResponse(fetchRequest, fetchResponse) {} requestFailedWithResponse(fetchRequest, fetchResponse) {} get #cacheTtl() { return Number(getMetaContent("turbo-prefetch-cache-time")) || cacheTtl } #isPrefetchable(link) { const href = link.getAttribute("href"); if (!href) return false if (unfetchableLink(link)) return false if (linkToTheSamePage(link)) return false if (linkOptsOut(link)) return false if (nonSafeLink(link)) return false if (eventPrevented(link)) return false return true } } const unfetchableLink = (link) => { return link.origin !== document.location.origin || !["http:", "https:"].includes(link.protocol) || link.hasAttribute("target") }; const linkToTheSamePage = (link) => { return (link.pathname + link.search === document.location.pathname + document.location.search) || link.href.startsWith("#") }; const linkOptsOut = (link) => { if (link.getAttribute("data-turbo-prefetch") === "false") return true if (link.getAttribute("data-turbo") === "false") return true const turboPrefetchParent = findClosestRecursively(link, "[data-turbo-prefetch]"); if (turboPrefetchParent && turboPrefetchParent.getAttribute("data-turbo-prefetch") === "false") return true return false }; const nonSafeLink = (link) => { const turboMethod = link.getAttribute("data-turbo-method"); if (turboMethod && turboMethod.toLowerCase() !== "get") return true if (isUJS(link)) return true if (link.hasAttribute("data-turbo-confirm")) return true if (link.hasAttribute("data-turbo-stream")) return true return false }; const isUJS = (link) => { return link.hasAttribute("data-remote") || link.hasAttribute("data-behavior") || link.hasAttribute("data-confirm") || link.hasAttribute("data-method") }; const eventPrevented = (link) => { const event = dispatch("turbo:before-prefetch", { target: link, cancelable: true }); return event.defaultPrevented }; class Navigator { constructor(delegate) { this.delegate = delegate; } proposeVisit(location, options = {}) { if (this.delegate.allowsVisitingLocationWithAction(location, options.action)) { this.delegate.visitProposedToLocation(location, options); } } startVisit(locatable, restorationIdentifier, options = {}) { this.stop(); this.currentVisit = new Visit(this, expandURL(locatable), restorationIdentifier, { referrer: this.location, ...options }); this.currentVisit.start(); } submitForm(form, submitter) { this.stop(); this.formSubmission = new FormSubmission(this, form, submitter, true); this.formSubmission.start(); } stop() { if (this.formSubmission) { this.formSubmission.stop(); delete this.formSubmission; } if (this.currentVisit) { this.currentVisit.cancel(); delete this.currentVisit; } } get adapter() { return this.delegate.adapter } get view() { return this.delegate.view } get rootLocation() { return this.view.snapshot.rootLocation } get history() { return this.delegate.history } // Form submission delegate formSubmissionStarted(formSubmission) { // Not all adapters implement formSubmissionStarted if (typeof this.adapter.formSubmissionStarted === "function") { this.adapter.formSubmissionStarted(formSubmission); } } async formSubmissionSucceededWithResponse(formSubmission, fetchResponse) { if (formSubmission == this.formSubmission) { const responseHTML = await fetchResponse.responseHTML; if (responseHTML) { const shouldCacheSnapshot = formSubmission.isSafe; if (!shouldCacheSnapshot) { this.view.clearSnapshotCache(); } const { statusCode, redirected } = fetchResponse; const action = this.#getActionForFormSubmission(formSubmission, fetchResponse); const visitOptions = { action, shouldCacheSnapshot, response: { statusCode, responseHTML, redirected } }; this.proposeVisit(fetchResponse.location, visitOptions); } } } async formSubmissionFailedWithResponse(formSubmission, fetchResponse) { const responseHTML = await fetchResponse.responseHTML; if (responseHTML) { const snapshot = PageSnapshot.fromHTMLString(responseHTML); if (fetchResponse.serverError) { await this.view.renderError(snapshot, this.currentVisit); } else { await this.view.renderPage(snapshot, false, true, this.currentVisit); } if(!snapshot.shouldPreserveScrollPosition) { this.view.scrollToTop(); } this.view.clearSnapshotCache(); } } formSubmissionErrored(formSubmission, error) { console.error(error); } formSubmissionFinished(formSubmission) { // Not all adapters implement formSubmissionFinished if (typeof this.adapter.formSubmissionFinished === "function") { this.adapter.formSubmissionFinished(formSubmission); } } // Visit delegate visitStarted(visit) { this.delegate.visitStarted(visit); } visitCompleted(visit) { this.delegate.visitCompleted(visit); delete this.currentVisit; } locationWithActionIsSamePage(location, action) { const anchor = getAnchor(location); const currentAnchor = getAnchor(this.view.lastRenderedLocation); const isRestorationToTop = action === "restore" && typeof anchor === "undefined"; return ( action !== "replace" && getRequestURL(location) === getRequestURL(this.view.lastRenderedLocation) && (isRestorationToTop || (anchor != null && anchor !== currentAnchor)) ) } visitScrolledToSamePageLocation(oldURL, newURL) { this.delegate.visitScrolledToSamePageLocation(oldURL, newURL); } // Visits get location() { return this.history.location } get restorationIdentifier() { return this.history.restorationIdentifier } #getActionForFormSubmission(formSubmission, fetchResponse) { const { submitter, formElement } = formSubmission; return getVisitAction(submitter, formElement) || this.#getDefaultAction(fetchResponse) } #getDefaultAction(fetchResponse) { const sameLocationRedirect = fetchResponse.redirected && fetchResponse.location.href === this.location?.href; return sameLocationRedirect ? "replace" : "advance" } } const PageStage = { initial: 0, loading: 1, interactive: 2, complete: 3 }; class PageObserver { stage = PageStage.initial started = false constructor(delegate) { this.delegate = delegate; } start() { if (!this.started) { if (this.stage == PageStage.initial) { this.stage = PageStage.loading; } document.addEventListener("readystatechange", this.interpretReadyState, false); addEventListener("pagehide", this.pageWillUnload, false); this.started = true; } } stop() { if (this.started) { document.removeEventListener("readystatechange", this.interpretReadyState, false); removeEventListener("pagehide", this.pageWillUnload, false); this.started = false; } } interpretReadyState = () => { const { readyState } = this; if (readyState == "interactive") { this.pageIsInteractive(); } else if (readyState == "complete") { this.pageIsComplete(); } } pageIsInteractive() { if (this.stage == PageStage.loading) { this.stage = PageStage.interactive; this.delegate.pageBecameInteractive(); } } pageIsComplete() { this.pageIsInteractive(); if (this.stage == PageStage.interactive) { this.stage = PageStage.complete; this.delegate.pageLoaded(); } } pageWillUnload = () => { this.delegate.pageWillUnload(); } get readyState() { return document.readyState } } class ScrollObserver { started = false constructor(delegate) { this.delegate = delegate; } start() { if (!this.started) { addEventListener("scroll", this.onScroll, false); this.onScroll(); this.started = true; } } stop() { if (this.started) { removeEventListener("scroll", this.onScroll, false); this.started = false; } } onScroll = () => { this.updatePosition({ x: window.pageXOffset, y: window.pageYOffset }); } // Private updatePosition(position) { this.delegate.scrollPositionChanged(position); } } class StreamMessageRenderer { render({ fragment }) { Bardo.preservingPermanentElements(this, getPermanentElementMapForFragment(fragment), () => { withAutofocusFromFragment(fragment, () => { withPreservedFocus(() => { document.documentElement.appendChild(fragment); }); }); }); } // Bardo delegate enteringBardo(currentPermanentElement, newPermanentElement) { newPermanentElement.replaceWith(currentPermanentElement.cloneNode(true)); } leavingBardo() {} } function getPermanentElementMapForFragment(fragment) { const permanentElementsInDocument = queryPermanentElementsAll(document.documentElement); const permanentElementMap = {}; for (const permanentElementInDocument of permanentElementsInDocument) { const { id } = permanentElementInDocument; for (const streamElement of fragment.querySelectorAll("turbo-stream")) { const elementInStream = getPermanentElementById(streamElement.templateElement.content, id); if (elementInStream) { permanentElementMap[id] = [permanentElementInDocument, elementInStream]; } } } return permanentElementMap } async function withAutofocusFromFragment(fragment, callback) { const generatedID = `turbo-stream-autofocus-${uuid()}`; const turboStreams = fragment.querySelectorAll("turbo-stream"); const elementWithAutofocus = firstAutofocusableElementInStreams(turboStreams); let willAutofocusId = null; if (elementWithAutofocus) { if (elementWithAutofocus.id) { willAutofocusId = elementWithAutofocus.id; } else { willAutofocusId = generatedID; } elementWithAutofocus.id = willAutofocusId; } callback(); await nextRepaint(); const hasNoActiveElement = document.activeElement == null || document.activeElement == document.body; if (hasNoActiveElement && willAutofocusId) { const elementToAutofocus = document.getElementById(willAutofocusId); if (elementIsFocusable(elementToAutofocus)) { elementToAutofocus.focus(); } if (elementToAutofocus && elementToAutofocus.id == generatedID) { elementToAutofocus.removeAttribute("id"); } } } async function withPreservedFocus(callback) { const [activeElementBeforeRender, activeElementAfterRender] = await around(callback, () => document.activeElement); const restoreFocusTo = activeElementBeforeRender && activeElementBeforeRender.id; if (restoreFocusTo) { const elementToFocus = document.getElementById(restoreFocusTo); if (elementIsFocusable(elementToFocus) && elementToFocus != activeElementAfterRender) { elementToFocus.focus(); } } } function firstAutofocusableElementInStreams(nodeListOfStreamElements) { for (const streamElement of nodeListOfStreamElements) { const elementWithAutofocus = queryAutofocusableElement(streamElement.templateElement.content); if (elementWithAutofocus) return elementWithAutofocus } return null } class StreamObserver { sources = new Set() #started = false constructor(delegate) { this.delegate = delegate; } start() { if (!this.#started) { this.#started = true; addEventListener("turbo:before-fetch-response", this.inspectFetchResponse, false); } } stop() { if (this.#started) { this.#started = false; removeEventListener("turbo:before-fetch-response", this.inspectFetchResponse, false); } } connectStreamSource(source) { if (!this.streamSourceIsConnected(source)) { this.sources.add(source); source.addEventListener("message", this.receiveMessageEvent, false); } } disconnectStreamSource(source) { if (this.streamSourceIsConnected(source)) { this.sources.delete(source); source.removeEventListener("message", this.receiveMessageEvent, false); } } streamSourceIsConnected(source) { return this.sources.has(source) } inspectFetchResponse = (event) => { const response = fetchResponseFromEvent(event); if (response && fetchResponseIsStream(response)) { event.preventDefault(); this.receiveMessageResponse(response); } } receiveMessageEvent = (event) => { if (this.#started && typeof event.data == "string") { this.receiveMessageHTML(event.data); } } async receiveMessageResponse(response) { const html = await response.responseHTML; if (html) { this.receiveMessageHTML(html); } } receiveMessageHTML(html) { this.delegate.receivedMessageFromStream(StreamMessage.wrap(html)); } } function fetchResponseFromEvent(event) { const fetchResponse = event.detail?.fetchResponse; if (fetchResponse instanceof FetchResponse) { return fetchResponse } } function fetchResponseIsStream(response) { const contentType = response.contentType ?? ""; return contentType.startsWith(StreamMessage.contentType) } class ErrorRenderer extends Renderer { static renderElement(currentElement, newElement) { const { documentElement, body } = document; documentElement.replaceChild(newElement, body); } async render() { this.replaceHeadAndBody(); this.activateScriptElements(); } replaceHeadAndBody() { const { documentElement, head } = document; documentElement.replaceChild(this.newHead, head); this.renderElement(this.currentElement, this.newElement); } activateScriptElements() { for (const replaceableElement of this.scriptElements) { const parentNode = replaceableElement.parentNode; if (parentNode) { const element = activateScriptElement(replaceableElement); parentNode.replaceChild(element, replaceableElement); } } } get newHead() { return this.newSnapshot.headSnapshot.element } get scriptElements() { return document.documentElement.querySelectorAll("script") } } // base IIFE to define idiomorph var Idiomorph = (function () { //============================================================================= // AND NOW IT BEGINS... //============================================================================= let EMPTY_SET = new Set(); // default configuration values, updatable by users now let defaults = { morphStyle: "outerHTML", callbacks : { beforeNodeAdded: noOp, afterNodeAdded: noOp, beforeNodeMorphed: noOp, afterNodeMorphed: noOp, beforeNodeRemoved: noOp, afterNodeRemoved: noOp, beforeAttributeUpdated: noOp, }, head: { style: 'merge', shouldPreserve: function (elt) { return elt.getAttribute("im-preserve") === "true"; }, shouldReAppend: function (elt) { return elt.getAttribute("im-re-append") === "true"; }, shouldRemove: noOp, afterHeadMorphed: noOp, } }; //============================================================================= // Core Morphing Algorithm - morph, morphNormalizedContent, morphOldNodeTo, morphChildren //============================================================================= function morph(oldNode, newContent, config = {}) { if (oldNode instanceof Document) { oldNode = oldNode.documentElement; } if (typeof newContent === 'string') { newContent = parseContent(newContent); } let normalizedContent = normalizeContent(newContent); let ctx = createMorphContext(oldNode, normalizedContent, config); return morphNormalizedContent(oldNode, normalizedContent, ctx); } function morphNormalizedContent(oldNode, normalizedNewContent, ctx) { if (ctx.head.block) { let oldHead = oldNode.querySelector('head'); let newHead = normalizedNewContent.querySelector('head'); if (oldHead && newHead) { let promises = handleHeadElement(newHead, oldHead, ctx); // when head promises resolve, call morph again, ignoring the head tag Promise.all(promises).then(function () { morphNormalizedContent(oldNode, normalizedNewContent, Object.assign(ctx, { head: { block: false, ignore: true } })); }); return; } } if (ctx.morphStyle === "innerHTML") { // innerHTML, so we are only updating the children morphChildren(normalizedNewContent, oldNode, ctx); return oldNode.children; } else if (ctx.morphStyle === "outerHTML" || ctx.morphStyle == null) { // otherwise find the best element match in the new content, morph that, and merge its siblings // into either side of the best match let bestMatch = findBestNodeMatch(normalizedNewContent, oldNode, ctx); // stash the siblings that will need to be inserted on either side of the best match let previousSibling = bestMatch?.previousSibling; let nextSibling = bestMatch?.nextSibling; // morph it let morphedNode = morphOldNodeTo(oldNode, bestMatch, ctx); if (bestMatch) { // if there was a best match, merge the siblings in too and return the // whole bunch return insertSiblings(previousSibling, morphedNode, nextSibling); } else { // otherwise nothing was added to the DOM return [] } } else { throw "Do not understand how to morph style " + ctx.morphStyle; } } /** * @param possibleActiveElement * @param ctx * @returns {boolean} */ function ignoreValueOfActiveElement(possibleActiveElement, ctx) { return ctx.ignoreActiveValue && possibleActiveElement === document.activeElement && possibleActiveElement !== document.body; } /** * @param oldNode root node to merge content into * @param newContent new content to merge * @param ctx the merge context * @returns {Element} the element that ended up in the DOM */ function morphOldNodeTo(oldNode, newContent, ctx) { if (ctx.ignoreActive && oldNode === document.activeElement) ; else if (newContent == null) { if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode; oldNode.remove(); ctx.callbacks.afterNodeRemoved(oldNode); return null; } else if (!isSoftMatch(oldNode, newContent)) { if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode; if (ctx.callbacks.beforeNodeAdded(newContent) === false) return oldNode; oldNode.parentElement.replaceChild(newContent, oldNode); ctx.callbacks.afterNodeAdded(newContent); ctx.callbacks.afterNodeRemoved(oldNode); return newContent; } else { if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) return oldNode; if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) ; else if (oldNode instanceof HTMLHeadElement && ctx.head.style !== "morph") { handleHeadElement(newContent, oldNode, ctx); } else { syncNodeFrom(newContent, oldNode, ctx); if (!ignoreValueOfActiveElement(oldNode, ctx)) { morphChildren(newContent, oldNode, ctx); } } ctx.callbacks.afterNodeMorphed(oldNode, newContent); return oldNode; } } /** * This is the core algorithm for matching up children. The idea is to use id sets to try to match up * nodes as faithfully as possible. We greedily match, which allows us to keep the algorithm fast, but * by using id sets, we are able to better match up with content deeper in the DOM. * * Basic algorithm is, for each node in the new content: * * - if we have reached the end of the old parent, append the new content * - if the new content has an id set match with the current insertion point, morph * - search for an id set match * - if id set match found, morph * - otherwise search for a "soft" match * - if a soft match is found, morph * - otherwise, prepend the new node before the current insertion point * * The two search algorithms terminate if competing node matches appear to outweigh what can be achieved * with the current node. See findIdSetMatch() and findSoftMatch() for details. * * @param {Element} newParent the parent element of the new content * @param {Element } oldParent the old content that we are merging the new content into * @param ctx the merge context */ function morphChildren(newParent, oldParent, ctx) { let nextNewChild = newParent.firstChild; let insertionPoint = oldParent.firstChild; let newChild; // run through all the new content while (nextNewChild) { newChild = nextNewChild; nextNewChild = newChild.nextSibling; // if we are at the end of the exiting parent's children, just append if (insertionPoint == null) { if (ctx.callbacks.beforeNodeAdded(newChild) === false) return; oldParent.appendChild(newChild); ctx.callbacks.afterNodeAdded(newChild); removeIdsFromConsideration(ctx, newChild); continue; } // if the current node has an id set match then morph if (isIdSetMatch(newChild, insertionPoint, ctx)) { morphOldNodeTo(insertionPoint, newChild, ctx); insertionPoint = insertionPoint.nextSibling; removeIdsFromConsideration(ctx, newChild); continue; } // otherwise search forward in the existing old children for an id set match let idSetMatch = findIdSetMatch(newParent, oldParent, newChild, insertionPoint, ctx); // if we found a potential match, remove the nodes until that point and morph if (idSetMatch) { insertionPoint = removeNodesBetween(insertionPoint, idSetMatch, ctx); morphOldNodeTo(idSetMatch, newChild, ctx); removeIdsFromConsideration(ctx, newChild); continue; } // no id set match found, so scan forward for a soft match for the current node let softMatch = findSoftMatch(newParent, oldParent, newChild, insertionPoint, ctx); // if we found a soft match for the current node, morph if (softMatch) { insertionPoint = removeNodesBetween(insertionPoint, softMatch, ctx); morphOldNodeTo(softMatch, newChild, ctx); removeIdsFromConsideration(ctx, newChild); continue; } // abandon all hope of morphing, just insert the new child before the insertion point // and move on if (ctx.callbacks.beforeNodeAdded(newChild) === false) return; oldParent.insertBefore(newChild, insertionPoint); ctx.callbacks.afterNodeAdded(newChild); removeIdsFromConsideration(ctx, newChild); } // remove any remaining old nodes that didn't match up with new content while (insertionPoint !== null) { let tempNode = insertionPoint; insertionPoint = insertionPoint.nextSibling; removeNode(tempNode, ctx); } } //============================================================================= // Attribute Syncing Code //============================================================================= /** * @param attr {String} the attribute to be mutated * @param to {Element} the element that is going to be updated * @param updateType {("update"|"remove")} * @param ctx the merge context * @returns {boolean} true if the attribute should be ignored, false otherwise */ function ignoreAttribute(attr, to, updateType, ctx) { if(attr === 'value' && ctx.ignoreActiveValue && to === document.activeElement){ return true; } return ctx.callbacks.beforeAttributeUpdated(attr, to, updateType) === false; } /** * syncs a given node with another node, copying over all attributes and * inner element state from the 'from' node to the 'to' node * * @param {Element} from the element to copy attributes & state from * @param {Element} to the element to copy attributes & state to * @param ctx the merge context */ function syncNodeFrom(from, to, ctx) { let type = from.nodeType; // if is an element type, sync the attributes from the // new node into the new node if (type === 1 /* element type */) { const fromAttributes = from.attributes; const toAttributes = to.attributes; for (const fromAttribute of fromAttributes) { if (ignoreAttribute(fromAttribute.name, to, 'update', ctx)) { continue; } if (to.getAttribute(fromAttribute.name) !== fromAttribute.value) { to.setAttribute(fromAttribute.name, fromAttribute.value); } } // iterate backwards to avoid skipping over items when a delete occurs for (let i = toAttributes.length - 1; 0 <= i; i--) { const toAttribute = toAttributes[i]; if (ignoreAttribute(toAttribute.name, to, 'remove', ctx)) { continue; } if (!from.hasAttribute(toAttribute.name)) { to.removeAttribute(toAttribute.name); } } } // sync text nodes if (type === 8 /* comment */ || type === 3 /* text */) { if (to.nodeValue !== from.nodeValue) { to.nodeValue = from.nodeValue; } } if (!ignoreValueOfActiveElement(to, ctx)) { // sync input values syncInputValue(from, to, ctx); } } /** * @param from {Element} element to sync the value from * @param to {Element} element to sync the value to * @param attributeName {String} the attribute name * @param ctx the merge context */ function syncBooleanAttribute(from, to, attributeName, ctx) { if (from[attributeName] !== to[attributeName]) { let ignoreUpdate = ignoreAttribute(attributeName, to, 'update', ctx); if (!ignoreUpdate) { to[attributeName] = from[attributeName]; } if (from[attributeName]) { if (!ignoreUpdate) { to.setAttribute(attributeName, from[attributeName]); } } else { if (!ignoreAttribute(attributeName, to, 'remove', ctx)) { to.removeAttribute(attributeName); } } } } /** * NB: many bothans died to bring us information: * * https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js * https://github.com/choojs/nanomorph/blob/master/lib/morph.jsL113 * * @param from {Element} the element to sync the input value from * @param to {Element} the element to sync the input value to * @param ctx the merge context */ function syncInputValue(from, to, ctx) { if (from instanceof HTMLInputElement && to instanceof HTMLInputElement && from.type !== 'file') { let fromValue = from.value; let toValue = to.value; // sync boolean attributes syncBooleanAttribute(from, to, 'checked', ctx); syncBooleanAttribute(from, to, 'disabled', ctx); if (!from.hasAttribute('value')) { if (!ignoreAttribute('value', to, 'remove', ctx)) { to.value = ''; to.removeAttribute('value'); } } else if (fromValue !== toValue) { if (!ignoreAttribute('value', to, 'update', ctx)) { to.setAttribute('value', fromValue); to.value = fromValue; } } } else if (from instanceof HTMLOptionElement) { syncBooleanAttribute(from, to, 'selected', ctx); } else if (from instanceof HTMLTextAreaElement && to instanceof HTMLTextAreaElement) { let fromValue = from.value; let toValue = to.value; if (ignoreAttribute('value', to, 'update', ctx)) { return; } if (fromValue !== toValue) { to.value = fromValue; } if (to.firstChild && to.firstChild.nodeValue !== fromValue) { to.firstChild.nodeValue = fromValue; } } } //============================================================================= // the HEAD tag can be handled specially, either w/ a 'merge' or 'append' style //============================================================================= function handleHeadElement(newHeadTag, currentHead, ctx) { let added = []; let removed = []; let preserved = []; let nodesToAppend = []; let headMergeStyle = ctx.head.style; // put all new head elements into a Map, by their outerHTML let srcToNewHeadNodes = new Map(); for (const newHeadChild of newHeadTag.children) { srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild); } // for each elt in the current head for (const currentHeadElt of currentHead.children) { // If the current head element is in the map let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML); let isReAppended = ctx.head.shouldReAppend(currentHeadElt); let isPreserved = ctx.head.shouldPreserve(currentHeadElt); if (inNewContent || isPreserved) { if (isReAppended) { // remove the current version and let the new version replace it and re-execute removed.push(currentHeadElt); } else { // this element already exists and should not be re-appended, so remove it from // the new content map, preserving it in the DOM srcToNewHeadNodes.delete(currentHeadElt.outerHTML); preserved.push(currentHeadElt); } } else { if (headMergeStyle === "append") { // we are appending and this existing element is not new content // so if and only if it is marked for re-append do we do anything if (isReAppended) { removed.push(currentHeadElt); nodesToAppend.push(currentHeadElt); } } else { // if this is a merge, we remove this content since it is not in the new head if (ctx.head.shouldRemove(currentHeadElt) !== false) { removed.push(currentHeadElt); } } } } // Push the remaining new head elements in the Map into the // nodes to append to the head tag nodesToAppend.push(...srcToNewHeadNodes.values()); let promises = []; for (const newNode of nodesToAppend) { let newElt = document.createRange().createContextualFragment(newNode.outerHTML).firstChild; if (ctx.callbacks.beforeNodeAdded(newElt) !== false) { if (newElt.href || newElt.src) { let resolve = null; let promise = new Promise(function (_resolve) { resolve = _resolve; }); newElt.addEventListener('load', function () { resolve(); }); promises.push(promise); } currentHead.appendChild(newElt); ctx.callbacks.afterNodeAdded(newElt); added.push(newElt); } } // remove all removed elements, after we have appended the new elements to avoid // additional network requests for things like style sheets for (const removedElement of removed) { if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) { currentHead.removeChild(removedElement); ctx.callbacks.afterNodeRemoved(removedElement); } } ctx.head.afterHeadMorphed(currentHead, {added: added, kept: preserved, removed: removed}); return promises; } function noOp() { } /* Deep merges the config object and the Idiomoroph.defaults object to produce a final configuration object */ function mergeDefaults(config) { let finalConfig = {}; // copy top level stuff into final config Object.assign(finalConfig, defaults); Object.assign(finalConfig, config); // copy callbacks into final config (do this to deep merge the callbacks) finalConfig.callbacks = {}; Object.assign(finalConfig.callbacks, defaults.callbacks); Object.assign(finalConfig.callbacks, config.callbacks); // copy head config into final config (do this to deep merge the head) finalConfig.head = {}; Object.assign(finalConfig.head, defaults.head); Object.assign(finalConfig.head, config.head); return finalConfig; } function createMorphContext(oldNode, newContent, config) { config = mergeDefaults(config); return { target: oldNode, newContent: newContent, config: config, morphStyle: config.morphStyle, ignoreActive: config.ignoreActive, ignoreActiveValue: config.ignoreActiveValue, idMap: createIdMap(oldNode, newContent), deadIds: new Set(), callbacks: config.callbacks, head: config.head } } function isIdSetMatch(node1, node2, ctx) { if (node1 == null || node2 == null) { return false; } if (node1.nodeType === node2.nodeType && node1.tagName === node2.tagName) { if (node1.id !== "" && node1.id === node2.id) { return true; } else { return getIdIntersectionCount(ctx, node1, node2) > 0; } } return false; } function isSoftMatch(node1, node2) { if (node1 == null || node2 == null) { return false; } return node1.nodeType === node2.nodeType && node1.tagName === node2.tagName } function removeNodesBetween(startInclusive, endExclusive, ctx) { while (startInclusive !== endExclusive) { let tempNode = startInclusive; startInclusive = startInclusive.nextSibling; removeNode(tempNode, ctx); } removeIdsFromConsideration(ctx, endExclusive); return endExclusive.nextSibling; } //============================================================================= // Scans forward from the insertionPoint in the old parent looking for a potential id match // for the newChild. We stop if we find a potential id match for the new child OR // if the number of potential id matches we are discarding is greater than the // potential id matches for the new child //============================================================================= function findIdSetMatch(newContent, oldParent, newChild, insertionPoint, ctx) { // max id matches we are willing to discard in our search let newChildPotentialIdCount = getIdIntersectionCount(ctx, newChild, oldParent); let potentialMatch = null; // only search forward if there is a possibility of an id match if (newChildPotentialIdCount > 0) { let potentialMatch = insertionPoint; // if there is a possibility of an id match, scan forward // keep track of the potential id match count we are discarding (the // newChildPotentialIdCount must be greater than this to make it likely // worth it) let otherMatchCount = 0; while (potentialMatch != null) { // If we have an id match, return the current potential match if (isIdSetMatch(newChild, potentialMatch, ctx)) { return potentialMatch; } // computer the other potential matches of this new content otherMatchCount += getIdIntersectionCount(ctx, potentialMatch, newContent); if (otherMatchCount > newChildPotentialIdCount) { // if we have more potential id matches in _other_ content, we // do not have a good candidate for an id match, so return null return null; } // advanced to the next old content child potentialMatch = potentialMatch.nextSibling; } } return potentialMatch; } //============================================================================= // Scans forward from the insertionPoint in the old parent looking for a potential soft match // for the newChild. We stop if we find a potential soft match for the new child OR // if we find a potential id match in the old parents children OR if we find two // potential soft matches for the next two pieces of new content //============================================================================= function findSoftMatch(newContent, oldParent, newChild, insertionPoint, ctx) { let potentialSoftMatch = insertionPoint; let nextSibling = newChild.nextSibling; let siblingSoftMatchCount = 0; while (potentialSoftMatch != null) { if (getIdIntersectionCount(ctx, potentialSoftMatch, newContent) > 0) { // the current potential soft match has a potential id set match with the remaining new // content so bail out of looking return null; } // if we have a soft match with the current node, return it if (isSoftMatch(newChild, potentialSoftMatch)) { return potentialSoftMatch; } if (isSoftMatch(nextSibling, potentialSoftMatch)) { // the next new node has a soft match with this node, so // increment the count of future soft matches siblingSoftMatchCount++; nextSibling = nextSibling.nextSibling; // If there are two future soft matches, bail to allow the siblings to soft match // so that we don't consume future soft matches for the sake of the current node if (siblingSoftMatchCount >= 2) { return null; } } // advanced to the next old content child potentialSoftMatch = potentialSoftMatch.nextSibling; } return potentialSoftMatch; } function parseContent(newContent) { let parser = new DOMParser(); // remove svgs to avoid false-positive matches on head, etc. let contentWithSvgsRemoved = newContent.replace(/]*>|>)([\s\S]*?)<\/svg>/gim, ''); // if the newContent contains a html, head or body tag, we can simply parse it w/o wrapping if (contentWithSvgsRemoved.match(/<\/html>/) || contentWithSvgsRemoved.match(/<\/head>/) || contentWithSvgsRemoved.match(/<\/body>/)) { let content = parser.parseFromString(newContent, "text/html"); // if it is a full HTML document, return the document itself as the parent container if (contentWithSvgsRemoved.match(/<\/html>/)) { content.generatedByIdiomorph = true; return content; } else { // otherwise return the html element as the parent container let htmlElement = content.firstChild; if (htmlElement) { htmlElement.generatedByIdiomorph = true; return htmlElement; } else { return null; } } } else { // if it is partial HTML, wrap it in a template tag to provide a parent element and also to help // deal with touchy tags like tr, tbody, etc. let responseDoc = parser.parseFromString("", "text/html"); let content = responseDoc.body.querySelector('template').content; content.generatedByIdiomorph = true; return content } } function normalizeContent(newContent) { if (newContent == null) { // noinspection UnnecessaryLocalVariableJS const dummyParent = document.createElement('div'); return dummyParent; } else if (newContent.generatedByIdiomorph) { // the template tag created by idiomorph parsing can serve as a dummy parent return newContent; } else if (newContent instanceof Node) { // a single node is added as a child to a dummy parent const dummyParent = document.createElement('div'); dummyParent.append(newContent); return dummyParent; } else { // all nodes in the array or HTMLElement collection are consolidated under // a single dummy parent element const dummyParent = document.createElement('div'); for (const elt of [...newContent]) { dummyParent.append(elt); } return dummyParent; } } function insertSiblings(previousSibling, morphedNode, nextSibling) { let stack = []; let added = []; while (previousSibling != null) { stack.push(previousSibling); previousSibling = previousSibling.previousSibling; } while (stack.length > 0) { let node = stack.pop(); added.push(node); // push added preceding siblings on in order and insert morphedNode.parentElement.insertBefore(node, morphedNode); } added.push(morphedNode); while (nextSibling != null) { stack.push(nextSibling); added.push(nextSibling); // here we are going in order, so push on as we scan, rather than add nextSibling = nextSibling.nextSibling; } while (stack.length > 0) { morphedNode.parentElement.insertBefore(stack.pop(), morphedNode.nextSibling); } return added; } function findBestNodeMatch(newContent, oldNode, ctx) { let currentElement; currentElement = newContent.firstChild; let bestElement = currentElement; let score = 0; while (currentElement) { let newScore = scoreElement(currentElement, oldNode, ctx); if (newScore > score) { bestElement = currentElement; score = newScore; } currentElement = currentElement.nextSibling; } return bestElement; } function scoreElement(node1, node2, ctx) { if (isSoftMatch(node1, node2)) { return .5 + getIdIntersectionCount(ctx, node1, node2); } return 0; } function removeNode(tempNode, ctx) { removeIdsFromConsideration(ctx, tempNode); if (ctx.callbacks.beforeNodeRemoved(tempNode) === false) return; tempNode.remove(); ctx.callbacks.afterNodeRemoved(tempNode); } //============================================================================= // ID Set Functions //============================================================================= function isIdInConsideration(ctx, id) { return !ctx.deadIds.has(id); } function idIsWithinNode(ctx, id, targetNode) { let idSet = ctx.idMap.get(targetNode) || EMPTY_SET; return idSet.has(id); } function removeIdsFromConsideration(ctx, node) { let idSet = ctx.idMap.get(node) || EMPTY_SET; for (const id of idSet) { ctx.deadIds.add(id); } } function getIdIntersectionCount(ctx, node1, node2) { let sourceSet = ctx.idMap.get(node1) || EMPTY_SET; let matchCount = 0; for (const id of sourceSet) { // a potential match is an id in the source and potentialIdsSet, but // that has not already been merged into the DOM if (isIdInConsideration(ctx, id) && idIsWithinNode(ctx, id, node2)) { ++matchCount; } } return matchCount; } /** * A bottom up algorithm that finds all elements with ids inside of the node * argument and populates id sets for those nodes and all their parents, generating * a set of ids contained within all nodes for the entire hierarchy in the DOM * * @param node {Element} * @param {Map>} idMap */ function populateIdMapForNode(node, idMap) { let nodeParent = node.parentElement; // find all elements with an id property let idElements = node.querySelectorAll('[id]'); for (const elt of idElements) { let current = elt; // walk up the parent hierarchy of that element, adding the id // of element to the parent's id set while (current !== nodeParent && current != null) { let idSet = idMap.get(current); // if the id set doesn't exist, create it and insert it in the map if (idSet == null) { idSet = new Set(); idMap.set(current, idSet); } idSet.add(elt.id); current = current.parentElement; } } } /** * This function computes a map of nodes to all ids contained within that node (inclusive of the * node). This map can be used to ask if two nodes have intersecting sets of ids, which allows * for a looser definition of "matching" than tradition id matching, and allows child nodes * to contribute to a parent nodes matching. * * @param {Element} oldContent the old content that will be morphed * @param {Element} newContent the new content to morph to * @returns {Map>} a map of nodes to id sets for the */ function createIdMap(oldContent, newContent) { let idMap = new Map(); populateIdMapForNode(oldContent, idMap); populateIdMapForNode(newContent, idMap); return idMap; } //============================================================================= // This is what ends up becoming the Idiomorph global object //============================================================================= return { morph, defaults } })(); function morphElements(currentElement, newElement, { callbacks, ...options } = {}) { Idiomorph.morph(currentElement, newElement, { ...options, callbacks: new DefaultIdiomorphCallbacks(callbacks) }); } function morphChildren(currentElement, newElement) { morphElements(currentElement, newElement.children, { morphStyle: "innerHTML" }); } class DefaultIdiomorphCallbacks { #beforeNodeMorphed constructor({ beforeNodeMorphed } = {}) { this.#beforeNodeMorphed = beforeNodeMorphed || (() => true); } beforeNodeAdded = (node) => { return !(node.id && node.hasAttribute("data-turbo-permanent") && document.getElementById(node.id)) } beforeNodeMorphed = (currentElement, newElement) => { if (currentElement instanceof Element) { if (!currentElement.hasAttribute("data-turbo-permanent") && this.#beforeNodeMorphed(currentElement, newElement)) { const event = dispatch("turbo:before-morph-element", { cancelable: true, target: currentElement, detail: { currentElement, newElement } }); return !event.defaultPrevented } else { return false } } } beforeAttributeUpdated = (attributeName, target, mutationType) => { const event = dispatch("turbo:before-morph-attribute", { cancelable: true, target, detail: { attributeName, mutationType } }); return !event.defaultPrevented } beforeNodeRemoved = (node) => { return this.beforeNodeMorphed(node) } afterNodeMorphed = (currentElement, newElement) => { if (currentElement instanceof Element) { dispatch("turbo:morph-element", { target: currentElement, detail: { currentElement, newElement } }); } } } class MorphingFrameRenderer extends FrameRenderer { static renderElement(currentElement, newElement) { dispatch("turbo:before-frame-morph", { target: currentElement, detail: { currentElement, newElement } }); morphChildren(currentElement, newElement); } } class PageRenderer extends Renderer { static renderElement(currentElement, newElement) { if (document.body && newElement instanceof HTMLBodyElement) { document.body.replaceWith(newElement); } else { document.documentElement.appendChild(newElement); } } get shouldRender() { return this.newSnapshot.isVisitable && this.trackedElementsAreIdentical } get reloadReason() { if (!this.newSnapshot.isVisitable) { return { reason: "turbo_visit_control_is_reload" } } if (!this.trackedElementsAreIdentical) { return { reason: "tracked_element_mismatch" } } } async prepareToRender() { this.#setLanguage(); await this.mergeHead(); } async render() { if (this.willRender) { await this.replaceBody(); } } finishRendering() { super.finishRendering(); if (!this.isPreview) { this.focusFirstAutofocusableElement(); } } get currentHeadSnapshot() { return this.currentSnapshot.headSnapshot } get newHeadSnapshot() { return this.newSnapshot.headSnapshot } get newElement() { return this.newSnapshot.element } #setLanguage() { const { documentElement } = this.currentSnapshot; const { lang } = this.newSnapshot; if (lang) { documentElement.setAttribute("lang", lang); } else { documentElement.removeAttribute("lang"); } } async mergeHead() { const mergedHeadElements = this.mergeProvisionalElements(); const newStylesheetElements = this.copyNewHeadStylesheetElements(); this.copyNewHeadScriptElements(); await mergedHeadElements; await newStylesheetElements; if (this.willRender) { this.removeUnusedDynamicStylesheetElements(); } } async replaceBody() { await this.preservingPermanentElements(async () => { this.activateNewBody(); await this.assignNewBody(); }); } get trackedElementsAreIdentical() { return this.currentHeadSnapshot.trackedElementSignature == this.newHeadSnapshot.trackedElementSignature } async copyNewHeadStylesheetElements() { const loadingElements = []; for (const element of this.newHeadStylesheetElements) { loadingElements.push(waitForLoad(element)); document.head.appendChild(element); } await Promise.all(loadingElements); } copyNewHeadScriptElements() { for (const element of this.newHeadScriptElements) { document.head.appendChild(activateScriptElement(element)); } } removeUnusedDynamicStylesheetElements() { for (const element of this.unusedDynamicStylesheetElements) { document.head.removeChild(element); } } async mergeProvisionalElements() { const newHeadElements = [...this.newHeadProvisionalElements]; for (const element of this.currentHeadProvisionalElements) { if (!this.isCurrentElementInElementList(element, newHeadElements)) { document.head.removeChild(element); } } for (const element of newHeadElements) { document.head.appendChild(element); } } isCurrentElementInElementList(element, elementList) { for (const [index, newElement] of elementList.entries()) { // if title element... if (element.tagName == "TITLE") { if (newElement.tagName != "TITLE") { continue } if (element.innerHTML == newElement.innerHTML) { elementList.splice(index, 1); return true } } // if any other element... if (newElement.isEqualNode(element)) { elementList.splice(index, 1); return true } } return false } removeCurrentHeadProvisionalElements() { for (const element of this.currentHeadProvisionalElements) { document.head.removeChild(element); } } copyNewHeadProvisionalElements() { for (const element of this.newHeadProvisionalElements) { document.head.appendChild(element); } } activateNewBody() { document.adoptNode(this.newElement); this.activateNewBodyScriptElements(); } activateNewBodyScriptElements() { for (const inertScriptElement of this.newBodyScriptElements) { const activatedScriptElement = activateScriptElement(inertScriptElement); inertScriptElement.replaceWith(activatedScriptElement); } } async assignNewBody() { await this.renderElement(this.currentElement, this.newElement); } get unusedDynamicStylesheetElements() { return this.oldHeadStylesheetElements.filter((element) => { return element.getAttribute("data-turbo-track") === "dynamic" }) } get oldHeadStylesheetElements() { return this.currentHeadSnapshot.getStylesheetElementsNotInSnapshot(this.newHeadSnapshot) } get newHeadStylesheetElements() { return this.newHeadSnapshot.getStylesheetElementsNotInSnapshot(this.currentHeadSnapshot) } get newHeadScriptElements() { return this.newHeadSnapshot.getScriptElementsNotInSnapshot(this.currentHeadSnapshot) } get currentHeadProvisionalElements() { return this.currentHeadSnapshot.provisionalElements } get newHeadProvisionalElements() { return this.newHeadSnapshot.provisionalElements } get newBodyScriptElements() { return this.newElement.querySelectorAll("script") } } class MorphingPageRenderer extends PageRenderer { static renderElement(currentElement, newElement) { morphElements(currentElement, newElement, { callbacks: { beforeNodeMorphed: element => !canRefreshFrame(element) } }); for (const frame of currentElement.querySelectorAll("turbo-frame")) { if (canRefreshFrame(frame)) refreshFrame(frame); } dispatch("turbo:morph", { detail: { currentElement, newElement } }); } async preservingPermanentElements(callback) { return await callback() } get renderMethod() { return "morph" } get shouldAutofocus() { return false } } function canRefreshFrame(frame) { return frame instanceof FrameElement && frame.src && frame.refresh === "morph" && !frame.closest("[data-turbo-permanent]") } function refreshFrame(frame) { frame.addEventListener("turbo:before-frame-render", ({ detail }) => { detail.render = MorphingFrameRenderer.renderElement; }, { once: true }); frame.reload(); } class SnapshotCache { keys = [] snapshots = {} constructor(size) { this.size = size; } has(location) { return toCacheKey(location) in this.snapshots } get(location) { if (this.has(location)) { const snapshot = this.read(location); this.touch(location); return snapshot } } put(location, snapshot) { this.write(location, snapshot); this.touch(location); return snapshot } clear() { this.snapshots = {}; } // Private read(location) { return this.snapshots[toCacheKey(location)] } write(location, snapshot) { this.snapshots[toCacheKey(location)] = snapshot; } touch(location) { const key = toCacheKey(location); const index = this.keys.indexOf(key); if (index > -1) this.keys.splice(index, 1); this.keys.unshift(key); this.trim(); } trim() { for (const key of this.keys.splice(this.size)) { delete this.snapshots[key]; } } } class PageView extends View { snapshotCache = new SnapshotCache(10) lastRenderedLocation = new URL(location.href) forceReloaded = false shouldTransitionTo(newSnapshot) { return this.snapshot.prefersViewTransitions && newSnapshot.prefersViewTransitions } renderPage(snapshot, isPreview = false, willRender = true, visit) { const shouldMorphPage = this.isPageRefresh(visit) && this.snapshot.shouldMorphPage; const rendererClass = shouldMorphPage ? MorphingPageRenderer : PageRenderer; const renderer = new rendererClass(this.snapshot, snapshot, rendererClass.renderElement, isPreview, willRender); if (!renderer.shouldRender) { this.forceReloaded = true; } else { visit?.changeHistory(); } return this.render(renderer) } renderError(snapshot, visit) { visit?.changeHistory(); const renderer = new ErrorRenderer(this.snapshot, snapshot, ErrorRenderer.renderElement, false); return this.render(renderer) } clearSnapshotCache() { this.snapshotCache.clear(); } async cacheSnapshot(snapshot = this.snapshot) { if (snapshot.isCacheable) { this.delegate.viewWillCacheSnapshot(); const { lastRenderedLocation: location } = this; await nextEventLoopTick(); const cachedSnapshot = snapshot.clone(); this.snapshotCache.put(location, cachedSnapshot); return cachedSnapshot } } getCachedSnapshotForLocation(location) { return this.snapshotCache.get(location) } isPageRefresh(visit) { return !visit || (this.lastRenderedLocation.pathname === visit.location.pathname && visit.action === "replace") } shouldPreserveScrollPosition(visit) { return this.isPageRefresh(visit) && this.snapshot.shouldPreserveScrollPosition } get snapshot() { return PageSnapshot.fromElement(this.element) } } class Preloader { selector = "a[data-turbo-preload]" constructor(delegate, snapshotCache) { this.delegate = delegate; this.snapshotCache = snapshotCache; } start() { if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", this.#preloadAll); } else { this.preloadOnLoadLinksForView(document.body); } } stop() { document.removeEventListener("DOMContentLoaded", this.#preloadAll); } preloadOnLoadLinksForView(element) { for (const link of element.querySelectorAll(this.selector)) { if (this.delegate.shouldPreloadLink(link)) { this.preloadURL(link); } } } async preloadURL(link) { const location = new URL(link.href); if (this.snapshotCache.has(location)) { return } const fetchRequest = new FetchRequest(this, FetchMethod.get, location, new URLSearchParams(), link); await fetchRequest.perform(); } // Fetch request delegate prepareRequest(fetchRequest) { fetchRequest.headers["X-Sec-Purpose"] = "prefetch"; } async requestSucceededWithResponse(fetchRequest, fetchResponse) { try { const responseHTML = await fetchResponse.responseHTML; const snapshot = PageSnapshot.fromHTMLString(responseHTML); this.snapshotCache.put(fetchRequest.url, snapshot); } catch (_) { // If we cannot preload that is ok! } } requestStarted(fetchRequest) {} requestErrored(fetchRequest) {} requestFinished(fetchRequest) {} requestPreventedHandlingResponse(fetchRequest, fetchResponse) {} requestFailedWithResponse(fetchRequest, fetchResponse) {} #preloadAll = () => { this.preloadOnLoadLinksForView(document.body); } } class Cache { constructor(session) { this.session = session; } clear() { this.session.clearCache(); } resetCacheControl() { this.#setCacheControl(""); } exemptPageFromCache() { this.#setCacheControl("no-cache"); } exemptPageFromPreview() { this.#setCacheControl("no-preview"); } #setCacheControl(value) { setMetaContent("turbo-cache-control", value); } } class Session { navigator = new Navigator(this) history = new History(this) view = new PageView(this, document.documentElement) adapter = new BrowserAdapter(this) pageObserver = new PageObserver(this) cacheObserver = new CacheObserver() linkPrefetchObserver = new LinkPrefetchObserver(this, document) linkClickObserver = new LinkClickObserver(this, window) formSubmitObserver = new FormSubmitObserver(this, document) scrollObserver = new ScrollObserver(this) streamObserver = new StreamObserver(this) formLinkClickObserver = new FormLinkClickObserver(this, document.documentElement) frameRedirector = new FrameRedirector(this, document.documentElement) streamMessageRenderer = new StreamMessageRenderer() cache = new Cache(this) drive = true enabled = true progressBarDelay = 500 started = false formMode = "on" #pageRefreshDebouncePeriod = 150 constructor(recentRequests) { this.recentRequests = recentRequests; this.preloader = new Preloader(this, this.view.snapshotCache); this.debouncedRefresh = this.refresh; this.pageRefreshDebouncePeriod = this.pageRefreshDebouncePeriod; } start() { if (!this.started) { this.pageObserver.start(); this.cacheObserver.start(); this.linkPrefetchObserver.start(); this.formLinkClickObserver.start(); this.linkClickObserver.start(); this.formSubmitObserver.start(); this.scrollObserver.start(); this.streamObserver.start(); this.frameRedirector.start(); this.history.start(); this.preloader.start(); this.started = true; this.enabled = true; } } disable() { this.enabled = false; } stop() { if (this.started) { this.pageObserver.stop(); this.cacheObserver.stop(); this.linkPrefetchObserver.stop(); this.formLinkClickObserver.stop(); this.linkClickObserver.stop(); this.formSubmitObserver.stop(); this.scrollObserver.stop(); this.streamObserver.stop(); this.frameRedirector.stop(); this.history.stop(); this.preloader.stop(); this.started = false; } } registerAdapter(adapter) { this.adapter = adapter; } visit(location, options = {}) { const frameElement = options.frame ? document.getElementById(options.frame) : null; if (frameElement instanceof FrameElement) { const action = options.action || getVisitAction(frameElement); frameElement.delegate.proposeVisitIfNavigatedWithAction(frameElement, action); frameElement.src = location.toString(); } else { this.navigator.proposeVisit(expandURL(location), options); } } refresh(url, requestId) { const isRecentRequest = requestId && this.recentRequests.has(requestId); if (!isRecentRequest && !this.navigator.currentVisit) { this.visit(url, { action: "replace", shouldCacheSnapshot: false }); } } connectStreamSource(source) { this.streamObserver.connectStreamSource(source); } disconnectStreamSource(source) { this.streamObserver.disconnectStreamSource(source); } renderStreamMessage(message) { this.streamMessageRenderer.render(StreamMessage.wrap(message)); } clearCache() { this.view.clearSnapshotCache(); } setProgressBarDelay(delay) { this.progressBarDelay = delay; } setFormMode(mode) { this.formMode = mode; } get location() { return this.history.location } get restorationIdentifier() { return this.history.restorationIdentifier } get pageRefreshDebouncePeriod() { return this.#pageRefreshDebouncePeriod } set pageRefreshDebouncePeriod(value) { this.refresh = debounce(this.debouncedRefresh.bind(this), value); this.#pageRefreshDebouncePeriod = value; } // Preloader delegate shouldPreloadLink(element) { const isUnsafe = element.hasAttribute("data-turbo-method"); const isStream = element.hasAttribute("data-turbo-stream"); const frameTarget = element.getAttribute("data-turbo-frame"); const frame = frameTarget == "_top" ? null : document.getElementById(frameTarget) || findClosestRecursively(element, "turbo-frame:not([disabled])"); if (isUnsafe || isStream || frame instanceof FrameElement) { return false } else { const location = new URL(element.href); return this.elementIsNavigatable(element) && locationIsVisitable(location, this.snapshot.rootLocation) } } // History delegate historyPoppedToLocationWithRestorationIdentifierAndDirection(location, restorationIdentifier, direction) { if (this.enabled) { this.navigator.startVisit(location, restorationIdentifier, { action: "restore", historyChanged: true, direction }); } else { this.adapter.pageInvalidated({ reason: "turbo_disabled" }); } } // Scroll observer delegate scrollPositionChanged(position) { this.history.updateRestorationData({ scrollPosition: position }); } // Form click observer delegate willSubmitFormLinkToLocation(link, location) { return this.elementIsNavigatable(link) && locationIsVisitable(location, this.snapshot.rootLocation) } submittedFormLinkToLocation() {} // Link hover observer delegate canPrefetchRequestToLocation(link, location) { return ( this.elementIsNavigatable(link) && locationIsVisitable(location, this.snapshot.rootLocation) ) } // Link click observer delegate willFollowLinkToLocation(link, location, event) { return ( this.elementIsNavigatable(link) && locationIsVisitable(location, this.snapshot.rootLocation) && this.applicationAllowsFollowingLinkToLocation(link, location, event) ) } followedLinkToLocation(link, location) { const action = this.getActionForLink(link); const acceptsStreamResponse = link.hasAttribute("data-turbo-stream"); this.visit(location.href, { action, acceptsStreamResponse }); } // Navigator delegate allowsVisitingLocationWithAction(location, action) { return this.locationWithActionIsSamePage(location, action) || this.applicationAllowsVisitingLocation(location) } visitProposedToLocation(location, options) { extendURLWithDeprecatedProperties(location); this.adapter.visitProposedToLocation(location, options); } // Visit delegate visitStarted(visit) { if (!visit.acceptsStreamResponse) { markAsBusy(document.documentElement); this.view.markVisitDirection(visit.direction); } extendURLWithDeprecatedProperties(visit.location); if (!visit.silent) { this.notifyApplicationAfterVisitingLocation(visit.location, visit.action); } } visitCompleted(visit) { this.view.unmarkVisitDirection(); clearBusyState(document.documentElement); this.notifyApplicationAfterPageLoad(visit.getTimingMetrics()); } locationWithActionIsSamePage(location, action) { return this.navigator.locationWithActionIsSamePage(location, action) } visitScrolledToSamePageLocation(oldURL, newURL) { this.notifyApplicationAfterVisitingSamePageLocation(oldURL, newURL); } // Form submit observer delegate willSubmitForm(form, submitter) { const action = getAction$1(form, submitter); return ( this.submissionIsNavigatable(form, submitter) && locationIsVisitable(expandURL(action), this.snapshot.rootLocation) ) } formSubmitted(form, submitter) { this.navigator.submitForm(form, submitter); } // Page observer delegate pageBecameInteractive() { this.view.lastRenderedLocation = this.location; this.notifyApplicationAfterPageLoad(); } pageLoaded() { this.history.assumeControlOfScrollRestoration(); } pageWillUnload() { this.history.relinquishControlOfScrollRestoration(); } // Stream observer delegate receivedMessageFromStream(message) { this.renderStreamMessage(message); } // Page view delegate viewWillCacheSnapshot() { if (!this.navigator.currentVisit?.silent) { this.notifyApplicationBeforeCachingSnapshot(); } } allowsImmediateRender({ element }, options) { const event = this.notifyApplicationBeforeRender(element, options); const { defaultPrevented, detail: { render } } = event; if (this.view.renderer && render) { this.view.renderer.renderElement = render; } return !defaultPrevented } viewRenderedSnapshot(_snapshot, _isPreview, renderMethod) { this.view.lastRenderedLocation = this.history.location; this.notifyApplicationAfterRender(renderMethod); } preloadOnLoadLinksForView(element) { this.preloader.preloadOnLoadLinksForView(element); } viewInvalidated(reason) { this.adapter.pageInvalidated(reason); } // Frame element frameLoaded(frame) { this.notifyApplicationAfterFrameLoad(frame); } frameRendered(fetchResponse, frame) { this.notifyApplicationAfterFrameRender(fetchResponse, frame); } // Application events applicationAllowsFollowingLinkToLocation(link, location, ev) { const event = this.notifyApplicationAfterClickingLinkToLocation(link, location, ev); return !event.defaultPrevented } applicationAllowsVisitingLocation(location) { const event = this.notifyApplicationBeforeVisitingLocation(location); return !event.defaultPrevented } notifyApplicationAfterClickingLinkToLocation(link, location, event) { return dispatch("turbo:click", { target: link, detail: { url: location.href, originalEvent: event }, cancelable: true }) } notifyApplicationBeforeVisitingLocation(location) { return dispatch("turbo:before-visit", { detail: { url: location.href }, cancelable: true }) } notifyApplicationAfterVisitingLocation(location, action) { return dispatch("turbo:visit", { detail: { url: location.href, action } }) } notifyApplicationBeforeCachingSnapshot() { return dispatch("turbo:before-cache") } notifyApplicationBeforeRender(newBody, options) { return dispatch("turbo:before-render", { detail: { newBody, ...options }, cancelable: true }) } notifyApplicationAfterRender(renderMethod) { return dispatch("turbo:render", { detail: { renderMethod } }) } notifyApplicationAfterPageLoad(timing = {}) { return dispatch("turbo:load", { detail: { url: this.location.href, timing } }) } notifyApplicationAfterVisitingSamePageLocation(oldURL, newURL) { dispatchEvent( new HashChangeEvent("hashchange", { oldURL: oldURL.toString(), newURL: newURL.toString() }) ); } notifyApplicationAfterFrameLoad(frame) { return dispatch("turbo:frame-load", { target: frame }) } notifyApplicationAfterFrameRender(fetchResponse, frame) { return dispatch("turbo:frame-render", { detail: { fetchResponse }, target: frame, cancelable: true }) } // Helpers submissionIsNavigatable(form, submitter) { if (this.formMode == "off") { return false } else { const submitterIsNavigatable = submitter ? this.elementIsNavigatable(submitter) : true; if (this.formMode == "optin") { return submitterIsNavigatable && form.closest('[data-turbo="true"]') != null } else { return submitterIsNavigatable && this.elementIsNavigatable(form) } } } elementIsNavigatable(element) { const container = findClosestRecursively(element, "[data-turbo]"); const withinFrame = findClosestRecursively(element, "turbo-frame"); // Check if Drive is enabled on the session or we're within a Frame. if (this.drive || withinFrame) { // Element is navigatable by default, unless `data-turbo="false"`. if (container) { return container.getAttribute("data-turbo") != "false" } else { return true } } else { // Element isn't navigatable by default, unless `data-turbo="true"`. if (container) { return container.getAttribute("data-turbo") == "true" } else { return false } } } // Private getActionForLink(link) { return getVisitAction(link) || "advance" } get snapshot() { return this.view.snapshot } } // Older versions of the Turbo Native adapters referenced the // `Location#absoluteURL` property in their implementations of // the `Adapter#visitProposedToLocation()` and `#visitStarted()` // methods. The Location class has since been removed in favor // of the DOM URL API, and accordingly all Adapter methods now // receive URL objects. // // We alias #absoluteURL to #toString() here to avoid crashing // older adapters which do not expect URL objects. We should // consider removing this support at some point in the future. function extendURLWithDeprecatedProperties(url) { Object.defineProperties(url, deprecatedLocationPropertyDescriptors); } const deprecatedLocationPropertyDescriptors = { absoluteURL: { get() { return this.toString() } } }; const session = new Session(recentRequests); const { cache, navigator: navigator$1 } = session; /** * Starts the main session. * This initialises any necessary observers such as those to monitor * link interactions. */ function start() { session.start(); } /** * Registers an adapter for the main session. * * @param adapter Adapter to register */ function registerAdapter(adapter) { session.registerAdapter(adapter); } /** * Performs an application visit to the given location. * * @param location Location to visit (a URL or path) * @param options Options to apply * @param options.action Type of history navigation to apply ("restore", * "replace" or "advance") * @param options.historyChanged Specifies whether the browser history has * already been changed for this visit or not * @param options.referrer Specifies the referrer of this visit such that * navigations to the same page will not result in a new history entry. * @param options.snapshotHTML Cached snapshot to render * @param options.response Response of the specified location */ function visit(location, options) { session.visit(location, options); } /** * Connects a stream source to the main session. * * @param source Stream source to connect */ function connectStreamSource(source) { session.connectStreamSource(source); } /** * Disconnects a stream source from the main session. * * @param source Stream source to disconnect */ function disconnectStreamSource(source) { session.disconnectStreamSource(source); } /** * Renders a stream message to the main session by appending it to the * current document. * * @param message Message to render */ function renderStreamMessage(message) { session.renderStreamMessage(message); } /** * Removes all entries from the Turbo Drive page cache. * Call this when state has changed on the server that may affect cached pages. * * @deprecated since version 7.2.0 in favor of `Turbo.cache.clear()` */ function clearCache() { console.warn( "Please replace `Turbo.clearCache()` with `Turbo.cache.clear()`. The top-level function is deprecated and will be removed in a future version of Turbo.`" ); session.clearCache(); } /** * Sets the delay after which the progress bar will appear during navigation. * * The progress bar appears after 500ms by default. * * Note that this method has no effect when used with the iOS or Android * adapters. * * @param delay Time to delay in milliseconds */ function setProgressBarDelay(delay) { session.setProgressBarDelay(delay); } function setConfirmMethod(confirmMethod) { FormSubmission.confirmMethod = confirmMethod; } function setFormMode(mode) { session.setFormMode(mode); } var Turbo = /*#__PURE__*/Object.freeze({ __proto__: null, navigator: navigator$1, session: session, cache: cache, PageRenderer: PageRenderer, PageSnapshot: PageSnapshot, FrameRenderer: FrameRenderer, fetch: fetchWithTurboHeaders, start: start, registerAdapter: registerAdapter, visit: visit, connectStreamSource: connectStreamSource, disconnectStreamSource: disconnectStreamSource, renderStreamMessage: renderStreamMessage, clearCache: clearCache, setProgressBarDelay: setProgressBarDelay, setConfirmMethod: setConfirmMethod, setFormMode: setFormMode }); class TurboFrameMissingError extends Error {} class FrameController { fetchResponseLoaded = (_fetchResponse) => Promise.resolve() #currentFetchRequest = null #resolveVisitPromise = () => {} #connected = false #hasBeenLoaded = false #ignoredAttributes = new Set() action = null constructor(element) { this.element = element; this.view = new FrameView(this, this.element); this.appearanceObserver = new AppearanceObserver(this, this.element); this.formLinkClickObserver = new FormLinkClickObserver(this, this.element); this.linkInterceptor = new LinkInterceptor(this, this.element); this.restorationIdentifier = uuid(); this.formSubmitObserver = new FormSubmitObserver(this, this.element); } // Frame delegate connect() { if (!this.#connected) { this.#connected = true; if (this.loadingStyle == FrameLoadingStyle.lazy) { this.appearanceObserver.start(); } else { this.#loadSourceURL(); } this.formLinkClickObserver.start(); this.linkInterceptor.start(); this.formSubmitObserver.start(); } } disconnect() { if (this.#connected) { this.#connected = false; this.appearanceObserver.stop(); this.formLinkClickObserver.stop(); this.linkInterceptor.stop(); this.formSubmitObserver.stop(); } } disabledChanged() { if (this.loadingStyle == FrameLoadingStyle.eager) { this.#loadSourceURL(); } } sourceURLChanged() { if (this.#isIgnoringChangesTo("src")) return if (this.element.isConnected) { this.complete = false; } if (this.loadingStyle == FrameLoadingStyle.eager || this.#hasBeenLoaded) { this.#loadSourceURL(); } } sourceURLReloaded() { const { src } = this.element; this.element.removeAttribute("complete"); this.element.src = null; this.element.src = src; return this.element.loaded } loadingStyleChanged() { if (this.loadingStyle == FrameLoadingStyle.lazy) { this.appearanceObserver.start(); } else { this.appearanceObserver.stop(); this.#loadSourceURL(); } } async #loadSourceURL() { if (this.enabled && this.isActive && !this.complete && this.sourceURL) { this.element.loaded = this.#visit(expandURL(this.sourceURL)); this.appearanceObserver.stop(); await this.element.loaded; this.#hasBeenLoaded = true; } } async loadResponse(fetchResponse) { if (fetchResponse.redirected || (fetchResponse.succeeded && fetchResponse.isHTML)) { this.sourceURL = fetchResponse.response.url; } try { const html = await fetchResponse.responseHTML; if (html) { const document = parseHTMLDocument(html); const pageSnapshot = PageSnapshot.fromDocument(document); if (pageSnapshot.isVisitable) { await this.#loadFrameResponse(fetchResponse, document); } else { await this.#handleUnvisitableFrameResponse(fetchResponse); } } } finally { this.fetchResponseLoaded = () => Promise.resolve(); } } // Appearance observer delegate elementAppearedInViewport(element) { this.proposeVisitIfNavigatedWithAction(element, getVisitAction(element)); this.#loadSourceURL(); } // Form link click observer delegate willSubmitFormLinkToLocation(link) { return this.#shouldInterceptNavigation(link) } submittedFormLinkToLocation(link, _location, form) { const frame = this.#findFrameElement(link); if (frame) form.setAttribute("data-turbo-frame", frame.id); } // Link interceptor delegate shouldInterceptLinkClick(element, _location, _event) { return this.#shouldInterceptNavigation(element) } linkClickIntercepted(element, location) { this.#navigateFrame(element, location); } // Form submit observer delegate willSubmitForm(element, submitter) { return element.closest("turbo-frame") == this.element && this.#shouldInterceptNavigation(element, submitter) } formSubmitted(element, submitter) { if (this.formSubmission) { this.formSubmission.stop(); } this.formSubmission = new FormSubmission(this, element, submitter); const { fetchRequest } = this.formSubmission; this.prepareRequest(fetchRequest); this.formSubmission.start(); } // Fetch request delegate prepareRequest(request) { request.headers["Turbo-Frame"] = this.id; if (this.currentNavigationElement?.hasAttribute("data-turbo-stream")) { request.acceptResponseType(StreamMessage.contentType); } } requestStarted(_request) { markAsBusy(this.element); } requestPreventedHandlingResponse(_request, _response) { this.#resolveVisitPromise(); } async requestSucceededWithResponse(request, response) { await this.loadResponse(response); this.#resolveVisitPromise(); } async requestFailedWithResponse(request, response) { await this.loadResponse(response); this.#resolveVisitPromise(); } requestErrored(request, error) { console.error(error); this.#resolveVisitPromise(); } requestFinished(_request) { clearBusyState(this.element); } // Form submission delegate formSubmissionStarted({ formElement }) { markAsBusy(formElement, this.#findFrameElement(formElement)); } formSubmissionSucceededWithResponse(formSubmission, response) { const frame = this.#findFrameElement(formSubmission.formElement, formSubmission.submitter); frame.delegate.proposeVisitIfNavigatedWithAction(frame, getVisitAction(formSubmission.submitter, formSubmission.formElement, frame)); frame.delegate.loadResponse(response); if (!formSubmission.isSafe) { session.clearCache(); } } formSubmissionFailedWithResponse(formSubmission, fetchResponse) { this.element.delegate.loadResponse(fetchResponse); session.clearCache(); } formSubmissionErrored(formSubmission, error) { console.error(error); } formSubmissionFinished({ formElement }) { clearBusyState(formElement, this.#findFrameElement(formElement)); } // View delegate allowsImmediateRender({ element: newFrame }, options) { const event = dispatch("turbo:before-frame-render", { target: this.element, detail: { newFrame, ...options }, cancelable: true }); const { defaultPrevented, detail: { render } } = event; if (this.view.renderer && render) { this.view.renderer.renderElement = render; } return !defaultPrevented } viewRenderedSnapshot(_snapshot, _isPreview, _renderMethod) {} preloadOnLoadLinksForView(element) { session.preloadOnLoadLinksForView(element); } viewInvalidated() {} // Frame renderer delegate willRenderFrame(currentElement, _newElement) { this.previousFrameElement = currentElement.cloneNode(true); } visitCachedSnapshot = ({ element }) => { const frame = element.querySelector("#" + this.element.id); if (frame && this.previousFrameElement) { frame.replaceChildren(...this.previousFrameElement.children); } delete this.previousFrameElement; } // Private async #loadFrameResponse(fetchResponse, document) { const newFrameElement = await this.extractForeignFrameElement(document.body); if (newFrameElement) { const snapshot = new Snapshot(newFrameElement); const renderer = new FrameRenderer(this, this.view.snapshot, snapshot, FrameRenderer.renderElement, false, false); if (this.view.renderPromise) await this.view.renderPromise; this.changeHistory(); await this.view.render(renderer); this.complete = true; session.frameRendered(fetchResponse, this.element); session.frameLoaded(this.element); await this.fetchResponseLoaded(fetchResponse); } else if (this.#willHandleFrameMissingFromResponse(fetchResponse)) { this.#handleFrameMissingFromResponse(fetchResponse); } } async #visit(url) { const request = new FetchRequest(this, FetchMethod.get, url, new URLSearchParams(), this.element); this.#currentFetchRequest?.cancel(); this.#currentFetchRequest = request; return new Promise((resolve) => { this.#resolveVisitPromise = () => { this.#resolveVisitPromise = () => {}; this.#currentFetchRequest = null; resolve(); }; request.perform(); }) } #navigateFrame(element, url, submitter) { const frame = this.#findFrameElement(element, submitter); frame.delegate.proposeVisitIfNavigatedWithAction(frame, getVisitAction(submitter, element, frame)); this.#withCurrentNavigationElement(element, () => { frame.src = url; }); } proposeVisitIfNavigatedWithAction(frame, action = null) { this.action = action; if (this.action) { const pageSnapshot = PageSnapshot.fromElement(frame).clone(); const { visitCachedSnapshot } = frame.delegate; frame.delegate.fetchResponseLoaded = async (fetchResponse) => { if (frame.src) { const { statusCode, redirected } = fetchResponse; const responseHTML = await fetchResponse.responseHTML; const response = { statusCode, redirected, responseHTML }; const options = { response, visitCachedSnapshot, willRender: false, updateHistory: false, restorationIdentifier: this.restorationIdentifier, snapshot: pageSnapshot }; if (this.action) options.action = this.action; session.visit(frame.src, options); } }; } } changeHistory() { if (this.action) { const method = getHistoryMethodForAction(this.action); session.history.update(method, expandURL(this.element.src || ""), this.restorationIdentifier); } } async #handleUnvisitableFrameResponse(fetchResponse) { console.warn( `The response (${fetchResponse.statusCode}) from is performing a full page visit due to turbo-visit-control.` ); await this.#visitResponse(fetchResponse.response); } #willHandleFrameMissingFromResponse(fetchResponse) { this.element.setAttribute("complete", ""); const response = fetchResponse.response; const visit = async (url, options) => { if (url instanceof Response) { this.#visitResponse(url); } else { session.visit(url, options); } }; const event = dispatch("turbo:frame-missing", { target: this.element, detail: { response, visit }, cancelable: true }); return !event.defaultPrevented } #handleFrameMissingFromResponse(fetchResponse) { this.view.missing(); this.#throwFrameMissingError(fetchResponse); } #throwFrameMissingError(fetchResponse) { const message = `The response (${fetchResponse.statusCode}) did not contain the expected and will be ignored. To perform a full page visit instead, set turbo-visit-control to reload.`; throw new TurboFrameMissingError(message) } async #visitResponse(response) { const wrapped = new FetchResponse(response); const responseHTML = await wrapped.responseHTML; const { location, redirected, statusCode } = wrapped; return session.visit(location, { response: { redirected, statusCode, responseHTML } }) } #findFrameElement(element, submitter) { const id = getAttribute("data-turbo-frame", submitter, element) || this.element.getAttribute("target"); return getFrameElementById(id) ?? this.element } async extractForeignFrameElement(container) { let element; const id = CSS.escape(this.id); try { element = activateElement(container.querySelector(`turbo-frame#${id}`), this.sourceURL); if (element) { return element } element = activateElement(container.querySelector(`turbo-frame[src][recurse~=${id}]`), this.sourceURL); if (element) { await element.loaded; return await this.extractForeignFrameElement(element) } } catch (error) { console.error(error); return new FrameElement() } return null } #formActionIsVisitable(form, submitter) { const action = getAction$1(form, submitter); return locationIsVisitable(expandURL(action), this.rootLocation) } #shouldInterceptNavigation(element, submitter) { const id = getAttribute("data-turbo-frame", submitter, element) || this.element.getAttribute("target"); if (element instanceof HTMLFormElement && !this.#formActionIsVisitable(element, submitter)) { return false } if (!this.enabled || id == "_top") { return false } if (id) { const frameElement = getFrameElementById(id); if (frameElement) { return !frameElement.disabled } } if (!session.elementIsNavigatable(element)) { return false } if (submitter && !session.elementIsNavigatable(submitter)) { return false } return true } // Computed properties get id() { return this.element.id } get enabled() { return !this.element.disabled } get sourceURL() { if (this.element.src) { return this.element.src } } set sourceURL(sourceURL) { this.#ignoringChangesToAttribute("src", () => { this.element.src = sourceURL ?? null; }); } get loadingStyle() { return this.element.loading } get isLoading() { return this.formSubmission !== undefined || this.#resolveVisitPromise() !== undefined } get complete() { return this.element.hasAttribute("complete") } set complete(value) { if (value) { this.element.setAttribute("complete", ""); } else { this.element.removeAttribute("complete"); } } get isActive() { return this.element.isActive && this.#connected } get rootLocation() { const meta = this.element.ownerDocument.querySelector(`meta[name="turbo-root"]`); const root = meta?.content ?? "/"; return expandURL(root) } #isIgnoringChangesTo(attributeName) { return this.#ignoredAttributes.has(attributeName) } #ignoringChangesToAttribute(attributeName, callback) { this.#ignoredAttributes.add(attributeName); callback(); this.#ignoredAttributes.delete(attributeName); } #withCurrentNavigationElement(element, callback) { this.currentNavigationElement = element; callback(); delete this.currentNavigationElement; } } function getFrameElementById(id) { if (id != null) { const element = document.getElementById(id); if (element instanceof FrameElement) { return element } } } function activateElement(element, currentURL) { if (element) { const src = element.getAttribute("src"); if (src != null && currentURL != null && urlsAreEqual(src, currentURL)) { throw new Error(`Matching element has a source URL which references itself`) } if (element.ownerDocument !== document) { element = document.importNode(element, true); } if (element instanceof FrameElement) { element.connectedCallback(); element.disconnectedCallback(); return element } } } const StreamActions = { after() { this.targetElements.forEach((e) => e.parentElement?.insertBefore(this.templateContent, e.nextSibling)); }, append() { this.removeDuplicateTargetChildren(); this.targetElements.forEach((e) => e.append(this.templateContent)); }, before() { this.targetElements.forEach((e) => e.parentElement?.insertBefore(this.templateContent, e)); }, prepend() { this.removeDuplicateTargetChildren(); this.targetElements.forEach((e) => e.prepend(this.templateContent)); }, remove() { this.targetElements.forEach((e) => e.remove()); }, replace() { const method = this.getAttribute("method"); this.targetElements.forEach((targetElement) => { if (method === "morph") { morphElements(targetElement, this.templateContent); } else { targetElement.replaceWith(this.templateContent); } }); }, update() { const method = this.getAttribute("method"); this.targetElements.forEach((targetElement) => { if (method === "morph") { morphChildren(targetElement, this.templateContent); } else { targetElement.innerHTML = ""; targetElement.append(this.templateContent); } }); }, refresh() { session.refresh(this.baseURI, this.requestId); } }; //