UNPKG

@hotwired/turbo

Version:

The speed of a single-page web application without having to write any JavaScript

7,179 lines (5,985 loc) 201 kB
/*! Turbo 8.0.13 Copyright © 2025 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 * <turbo-frame id="messages"> * <a href="/messages/expanded"> * Show all expanded messages in this frame. * </a> * * <form action="/messages"> * Show response from this form within this frame. * </form> * </turbo-frame> */ class FrameElement extends HTMLElement { static delegateConstructor = undefined loaded = Promise.resolve() static get observedAttributes() { return ["disabled", "loading", "src"] } constructor() { super(); this.delegate = new FrameElement.delegateConstructor(this); } connectedCallback() { this.delegate.connect(); } disconnectedCallback() { this.delegate.disconnect(); } reload() { return this.delegate.sourceURLReloaded() } attributeChangedCallback(name) { if (name == "loading") { this.delegate.loadingStyleChanged(); } else if (name == "src") { this.delegate.sourceURLChanged(); } else if (name == "disabled") { this.delegate.disabledChanged(); } } /** * Gets the URL to lazily load source HTML from */ get src() { return this.getAttribute("src") } /** * Sets the URL to lazily load source HTML from */ set src(value) { if (value) { this.setAttribute("src", value); } else { this.removeAttribute("src"); } } /** * Gets the refresh mode for the frame. */ get refresh() { return this.getAttribute("refresh") } /** * Sets the refresh mode for the frame. */ set refresh(value) { if (value) { this.setAttribute("refresh", value); } else { this.removeAttribute("refresh"); } } get shouldReloadWithMorph() { return this.src && this.refresh === "morph" } /** * Determines if the element is loading */ get loading() { return frameLoadingStyleFromString(this.getAttribute("loading") || "") } /** * Sets the value of if the element is loading */ set loading(value) { if (value) { this.setAttribute("loading", value); } else { this.removeAttribute("loading"); } } /** * Gets the disabled state of the frame. * * If disabled, no requests will be intercepted by the frame. */ get disabled() { return this.hasAttribute("disabled") } /** * Sets the disabled state of the frame. * * If disabled, no requests will be intercepted by the frame. */ set disabled(value) { if (value) { this.setAttribute("disabled", ""); } else { this.removeAttribute("disabled"); } } /** * Gets the autoscroll state of the frame. * * If true, the frame will be scrolled into view automatically on update. */ get autoscroll() { return this.hasAttribute("autoscroll") } /** * Sets the autoscroll state of the frame. * * If true, the frame will be scrolled into view automatically on update. */ set autoscroll(value) { if (value) { this.setAttribute("autoscroll", ""); } else { this.removeAttribute("autoscroll"); } } /** * Determines if the element has finished loading */ get complete() { return !this.delegate.isLoading } /** * Gets the active state of the frame. * * If inactive, source changes will not be observed. */ get isActive() { return this.ownerDocument === document && !this.isPreview } /** * Sets the active state of the frame. * * If inactive, source changes will not be observed. */ get isPreview() { return this.ownerDocument?.documentElement?.hasAttribute("data-turbo-preview") } } function frameLoadingStyleFromString(style) { switch (style.toLowerCase()) { case "lazy": return FrameLoadingStyle.lazy default: return FrameLoadingStyle.eager } } const drive = { enabled: true, progressBarDelay: 500, unvisitableExtensions: new Set( [ ".7z", ".aac", ".apk", ".avi", ".bmp", ".bz2", ".css", ".csv", ".deb", ".dmg", ".doc", ".docx", ".exe", ".gif", ".gz", ".heic", ".heif", ".ico", ".iso", ".jpeg", ".jpg", ".js", ".json", ".m4a", ".mkv", ".mov", ".mp3", ".mp4", ".mpeg", ".mpg", ".msi", ".ogg", ".ogv", ".pdf", ".pkg", ".png", ".ppt", ".pptx", ".rar", ".rtf", ".svg", ".tar", ".tif", ".tiff", ".txt", ".wav", ".webm", ".webp", ".wma", ".wmv", ".xls", ".xlsx", ".xml", ".zip" ] ) }; function activateScriptElement(element) { if (element.getAttribute("data-turbo-eval") == "false") { return element } else { const createdScriptElement = document.createElement("script"); const cspNonce = getCspNonce(); if (cspNonce) { createdScriptElement.nonce = cspNonce; } createdScriptElement.textContent = element.textContent; createdScriptElement.async = false; copyElementAttributes(createdScriptElement, element); return createdScriptElement } } function copyElementAttributes(destinationElement, sourceElement) { for (const { name, value } of sourceElement.attributes) { destinationElement.setAttribute(name, value); } } function createDocumentFragment(html) { const template = document.createElement("template"); template.innerHTML = html; return template.content } function dispatch(eventName, { target, cancelable, detail } = {}) { const event = new CustomEvent(eventName, { cancelable, bubbles: true, composed: true, detail }); if (target && target.isConnected) { target.dispatchEvent(event); } else { document.documentElement.dispatchEvent(event); } return event } function cancelEvent(event) { event.preventDefault(); event.stopImmediatePropagation(); } function nextRepaint() { if (document.visibilityState === "hidden") { return nextEventLoopTick() } else { return nextAnimationFrame() } } function nextAnimationFrame() { return new Promise((resolve) => requestAnimationFrame(() => resolve())) } function nextEventLoopTick() { return new Promise((resolve) => setTimeout(() => resolve(), 0)) } function nextMicrotask() { return Promise.resolve() } function parseHTMLDocument(html = "") { return new DOMParser().parseFromString(html, "text/html") } function unindent(strings, ...values) { const lines = interpolate(strings, values).replace(/^\n/, "").split("\n"); const match = lines[0].match(/^\s+/); const indent = match ? match[0].length : 0; return lines.map((line) => line.slice(indent)).join("\n") } function interpolate(strings, values) { return strings.reduce((result, string, i) => { const value = values[i] == undefined ? "" : values[i]; return result + string + value }, "") } function uuid() { return Array.from({ length: 36 }) .map((_, i) => { if (i == 8 || i == 13 || i == 18 || i == 23) { return "-" } else if (i == 14) { return "4" } else if (i == 19) { return (Math.floor(Math.random() * 4) + 8).toString(16) } else { return Math.floor(Math.random() * 15).toString(16) } }) .join("") } function getAttribute(attributeName, ...elements) { for (const value of elements.map((element) => element?.getAttribute(attributeName))) { if (typeof value == "string") return value } return null } function hasAttribute(attributeName, ...elements) { return elements.some((element) => element && element.hasAttribute(attributeName)) } function markAsBusy(...elements) { for (const element of elements) { if (element.localName == "turbo-frame") { element.setAttribute("busy", ""); } element.setAttribute("aria-busy", "true"); } } function clearBusyState(...elements) { for (const element of elements) { if (element.localName == "turbo-frame") { element.removeAttribute("busy"); } element.removeAttribute("aria-busy"); } } function waitForLoad(element, timeoutInMilliseconds = 2000) { return new Promise((resolve) => { const onComplete = () => { element.removeEventListener("error", onComplete); element.removeEventListener("load", onComplete); resolve(); }; element.addEventListener("load", onComplete, { once: true }); element.addEventListener("error", onComplete, { once: true }); setTimeout(resolve, timeoutInMilliseconds); }) } function getHistoryMethodForAction(action) { switch (action) { case "replace": return history.replaceState case "advance": case "restore": return history.pushState } } function isAction(action) { return action == "advance" || action == "replace" || action == "restore" } function getVisitAction(...elements) { const action = getAttribute("data-turbo-action", ...elements); return isAction(action) ? action : null } function getMetaElement(name) { return document.querySelector(`meta[name="${name}"]`) } function getMetaContent(name) { const element = getMetaElement(name); return element && element.content } function getCspNonce() { const element = getMetaElement("csp-nonce"); if (element) { const { nonce, content } = element; return nonce == "" ? content : nonce } } function setMetaContent(name, content) { let element = getMetaElement(name); if (!element) { element = document.createElement("meta"); element.setAttribute("name", name); document.head.appendChild(element); } element.setAttribute("content", content); return element } function findClosestRecursively(element, selector) { if (element instanceof Element) { return ( element.closest(selector) || findClosestRecursively(element.assignedSlot || element.getRootNode()?.host, selector) ) } } function elementIsFocusable(element) { const inertDisabledOrHidden = "[inert], :disabled, [hidden], details:not([open]), dialog:not([open])"; return !!element && element.closest(inertDisabledOrHidden) == null && typeof element.focus == "function" } function queryAutofocusableElement(elementOrDocumentFragment) { return Array.from(elementOrDocumentFragment.querySelectorAll("[autofocus]")).find(elementIsFocusable) } async function around(callback, reader) { const before = reader(); callback(); await nextAnimationFrame(); const after = reader(); return [before, after] } function doesNotTargetIFrame(name) { if (name === "_blank") { return false } else if (name) { for (const element of document.getElementsByName(name)) { if (element instanceof HTMLIFrameElement) return false } return true } else { return true } } function findLinkFromClickTarget(target) { return findClosestRecursively(target, "a[href]:not([target^=_]):not([download])") } function getLocationForLink(link) { return expandURL(link.getAttribute("href") || "") } function debounce(fn, delay) { let timeoutId = null; return (...args) => { const callback = () => fn.apply(this, args); clearTimeout(timeoutId); timeoutId = setTimeout(callback, delay); } } const submitter = { "aria-disabled": { beforeSubmit: submitter => { submitter.setAttribute("aria-disabled", "true"); submitter.addEventListener("click", cancelEvent); }, afterSubmit: submitter => { submitter.removeAttribute("aria-disabled"); submitter.removeEventListener("click", cancelEvent); } }, "disabled": { beforeSubmit: submitter => submitter.disabled = true, afterSubmit: submitter => submitter.disabled = false } }; class Config { #submitter = null constructor(config) { Object.assign(this, config); } get submitter() { return this.#submitter } set submitter(value) { this.#submitter = submitter[value] || value; } } const forms = new Config({ mode: "on", submitter: "disabled" }); const config = { drive, forms }; function expandURL(locatable) { return new URL(locatable.toString(), document.baseURI) } function getAnchor(url) { let anchorMatch; if (url.hash) { return url.hash.slice(1) // eslint-disable-next-line no-cond-assign } else if ((anchorMatch = url.href.match(/#(.*)$/))) { return anchorMatch[1] } } function getAction$1(form, submitter) { const action = submitter?.getAttribute("formaction") || form.getAttribute("action") || form.action; return expandURL(action) } function getExtension(url) { return (getLastPathComponent(url).match(/\.[^.]*$/) || [])[0] || "" } function isPrefixedBy(baseURL, url) { const prefix = getPrefix(url); return baseURL.href === expandURL(prefix).href || baseURL.href.startsWith(prefix) } function locationIsVisitable(location, rootLocation) { return isPrefixedBy(location, rootLocation) && !config.drive.unvisitableExtensions.has(getExtension(location)) } function getRequestURL(url) { const anchor = getAnchor(url); return anchor != null ? url.href.slice(0, -(anchor.length + 1)) : url.href } function toCacheKey(url) { return getRequestURL(url) } function urlsAreEqual(left, right) { return expandURL(left).href == expandURL(right).href } function getPathComponents(url) { return url.pathname.split("/").slice(1) } function getLastPathComponent(url) { return getPathComponents(url).slice(-1)[0] } function getPrefix(url) { return addTrailingSlash(url.origin + url.pathname) } function addTrailingSlash(value) { return value.endsWith("/") ? value : value + "/" } class FetchResponse { constructor(response) { this.response = response; } get succeeded() { return this.response.ok } get failed() { return !this.succeeded } get clientError() { return this.statusCode >= 400 && this.statusCode <= 499 } get serverError() { return this.statusCode >= 500 && this.statusCode <= 599 } get redirected() { return this.response.redirected } get location() { return expandURL(this.response.url) } get isHTML() { return this.contentType && this.contentType.match(/^(?:text\/([^\s;,]+\b)?html|application\/xhtml\+xml)\b/) } get statusCode() { return this.response.status } get contentType() { return this.header("Content-Type") } get responseText() { return this.response.clone().text() } get responseHTML() { if (this.isHTML) { return this.response.clone().text() } else { return Promise.resolve(undefined) } } header(name) { return this.response.headers.get(name) } } class LimitedSet extends Set { constructor(maxSize) { super(); this.maxSize = maxSize; } add(value) { if (this.size >= this.maxSize) { const iterator = this.values(); const oldestValue = iterator.next().value; this.delete(oldestValue); } super.add(value); } } const recentRequests = new LimitedSet(20); const nativeFetch = window.fetch; function fetchWithTurboHeaders(url, options = {}) { const modifiedHeaders = new Headers(options.headers || {}); const requestUID = uuid(); recentRequests.add(requestUID); modifiedHeaders.append("X-Turbo-Request-Id", requestUID); return nativeFetch(url, { ...options, headers: modifiedHeaders }) } function fetchMethodFromString(method) { switch (method.toLowerCase()) { case "get": return FetchMethod.get case "post": return FetchMethod.post case "put": return FetchMethod.put case "patch": return FetchMethod.patch case "delete": return FetchMethod.delete } } const FetchMethod = { get: "get", post: "post", put: "put", patch: "patch", delete: "delete" }; function fetchEnctypeFromString(encoding) { switch (encoding.toLowerCase()) { case FetchEnctype.multipart: return FetchEnctype.multipart case FetchEnctype.plain: return FetchEnctype.plain default: return FetchEnctype.urlEncoded } } const FetchEnctype = { urlEncoded: "application/x-www-form-urlencoded", multipart: "multipart/form-data", plain: "text/plain" }; class FetchRequest { abortController = new AbortController() #resolveRequestPromise = (_value) => {} constructor(delegate, method, location, requestBody = new URLSearchParams(), target = null, enctype = FetchEnctype.urlEncoded) { const [url, body] = buildResourceAndBody(expandURL(location), method, requestBody, enctype); this.delegate = delegate; this.url = url; this.target = target; this.fetchOptions = { credentials: "same-origin", redirect: "follow", method: method.toUpperCase(), headers: { ...this.defaultHeaders }, body: body, signal: this.abortSignal, referrer: this.delegate.referrer?.href }; this.enctype = enctype; } get method() { return this.fetchOptions.method } set method(value) { const fetchBody = this.isSafe ? this.url.searchParams : this.fetchOptions.body || new FormData(); const fetchMethod = fetchMethodFromString(value) || FetchMethod.get; this.url.search = ""; const [url, body] = buildResourceAndBody(this.url, fetchMethod, fetchBody, this.enctype); this.url = url; this.fetchOptions.body = body; this.fetchOptions.method = fetchMethod.toUpperCase(); } get headers() { return this.fetchOptions.headers } set headers(value) { this.fetchOptions.headers = value; } get body() { if (this.isSafe) { return this.url.searchParams } else { return this.fetchOptions.body } } set body(value) { this.fetchOptions.body = value; } get location() { return this.url } get params() { return this.url.searchParams } get entries() { return this.body ? Array.from(this.body.entries()) : [] } cancel() { this.abortController.abort(); } async perform() { const { fetchOptions } = this; this.delegate.prepareRequest(this); const event = await this.#allowRequestToBeIntercepted(fetchOptions); try { this.delegate.requestStarted(this); if (event.detail.fetchRequest) { this.response = event.detail.fetchRequest.response; } else { this.response = fetchWithTurboHeaders(this.url.href, fetchOptions); } const response = await this.response; return await this.receive(response) } catch (error) { if (error.name !== "AbortError") { if (this.#willDelegateErrorHandling(error)) { this.delegate.requestErrored(this, error); } throw error } } finally { this.delegate.requestFinished(this); } } async receive(response) { const fetchResponse = new FetchResponse(response); const event = dispatch("turbo:before-fetch-response", { cancelable: true, detail: { fetchResponse }, target: this.target }); if (event.defaultPrevented) { this.delegate.requestPreventedHandlingResponse(this, fetchResponse); } else if (fetchResponse.succeeded) { this.delegate.requestSucceededWithResponse(this, fetchResponse); } else { this.delegate.requestFailedWithResponse(this, fetchResponse); } return fetchResponse } get defaultHeaders() { return { Accept: "text/html, application/xhtml+xml" } } get isSafe() { return isSafe(this.method) } get abortSignal() { return this.abortController.signal } acceptResponseType(mimeType) { this.headers["Accept"] = [mimeType, this.headers["Accept"]].join(", "); } async #allowRequestToBeIntercepted(fetchOptions) { const requestInterception = new Promise((resolve) => (this.#resolveRequestPromise = resolve)); const event = dispatch("turbo:before-fetch-request", { cancelable: true, detail: { fetchOptions, url: this.url, resume: this.#resolveRequestPromise }, target: this.target }); this.url = event.detail.url; if (event.defaultPrevented) await requestInterception; return event } #willDelegateErrorHandling(error) { const event = dispatch("turbo:fetch-request-error", { target: this.target, cancelable: true, detail: { request: this, error: error } }); return !event.defaultPrevented } } function isSafe(fetchMethod) { return fetchMethodFromString(fetchMethod) == FetchMethod.get } function buildResourceAndBody(resource, method, requestBody, enctype) { const searchParams = Array.from(requestBody).length > 0 ? new URLSearchParams(entriesExcludingFiles(requestBody)) : resource.searchParams; if (isSafe(method)) { return [mergeIntoURLSearchParams(resource, searchParams), null] } else if (enctype == FetchEnctype.urlEncoded) { return [resource, searchParams] } else { return [resource, requestBody] } } function entriesExcludingFiles(requestBody) { const entries = []; for (const [name, value] of requestBody) { if (value instanceof File) continue else entries.push([name, value]); } return entries } function mergeIntoURLSearchParams(url, requestBody) { const searchParams = new URLSearchParams(entriesExcludingFiles(requestBody)); url.search = searchParams.toString(); return url } class AppearanceObserver { started = false constructor(delegate, element) { this.delegate = delegate; this.element = element; this.intersectionObserver = new IntersectionObserver(this.intersect); } start() { if (!this.started) { this.started = true; this.intersectionObserver.observe(this.element); } } stop() { if (this.started) { this.started = false; this.intersectionObserver.unobserve(this.element); } } intersect = (entries) => { const lastEntry = entries.slice(-1)[0]; if (lastEntry?.isIntersecting) { this.delegate.elementAppearedInViewport(this.element); } } } class StreamMessage { static contentType = "text/vnd.turbo-stream.html" static wrap(message) { if (typeof message == "string") { return new this(createDocumentFragment(message)) } else { return message } } constructor(fragment) { this.fragment = importStreamElements(fragment); } } function importStreamElements(fragment) { for (const element of fragment.querySelectorAll("turbo-stream")) { const streamElement = document.importNode(element, true); for (const inertScriptElement of streamElement.templateElement.content.querySelectorAll("script")) { inertScriptElement.replaceWith(activateScriptElement(inertScriptElement)); } element.replaceWith(streamElement); } return fragment } const PREFETCH_DELAY = 100; class PrefetchCache { #prefetchTimeout = null #prefetched = null get(url) { if (this.#prefetched && this.#prefetched.url === url && this.#prefetched.expire > Date.now()) { return this.#prefetched.request } } setLater(url, request, ttl) { this.clear(); this.#prefetchTimeout = setTimeout(() => { request.perform(); this.set(url, request, ttl); this.#prefetchTimeout = null; }, PREFETCH_DELAY); } set(url, request, ttl) { this.#prefetched = { url, request, expire: new Date(new Date().getTime() + ttl) }; } clear() { if (this.#prefetchTimeout) clearTimeout(this.#prefetchTimeout); this.#prefetched = null; } } const cacheTtl = 10 * 1000; const prefetchCache = new PrefetchCache(); const FormSubmissionState = { initialized: "initialized", requesting: "requesting", waiting: "waiting", receiving: "receiving", stopping: "stopping", stopped: "stopped" }; class FormSubmission { state = FormSubmissionState.initialized static confirmMethod(message) { return Promise.resolve(confirm(message)) } constructor(delegate, formElement, submitter, mustRedirect = false) { const method = getMethod(formElement, submitter); const action = getAction(getFormAction(formElement, submitter), method); const body = buildFormData(formElement, submitter); const enctype = getEnctype(formElement, submitter); this.delegate = delegate; this.formElement = formElement; this.submitter = submitter; this.fetchRequest = new FetchRequest(this, method, action, body, formElement, enctype); this.mustRedirect = mustRedirect; } get method() { return this.fetchRequest.method } set method(value) { this.fetchRequest.method = value; } get action() { return this.fetchRequest.url.toString() } set action(value) { this.fetchRequest.url = expandURL(value); } get body() { return this.fetchRequest.body } get enctype() { return this.fetchRequest.enctype } get isSafe() { return this.fetchRequest.isSafe } get location() { return this.fetchRequest.url } // The submission process async start() { const { initialized, requesting } = FormSubmissionState; const confirmationMessage = getAttribute("data-turbo-confirm", this.submitter, this.formElement); if (typeof confirmationMessage === "string") { const confirmMethod = typeof config.forms.confirm === "function" ? config.forms.confirm : FormSubmission.confirmMethod; const answer = await confirmMethod(confirmationMessage, this.formElement, this.submitter); if (!answer) { return } } if (this.state == initialized) { this.state = requesting; return this.fetchRequest.perform() } } stop() { const { stopping, stopped } = FormSubmissionState; if (this.state != stopping && this.state != stopped) { this.state = stopping; this.fetchRequest.cancel(); return true } } // Fetch request delegate prepareRequest(request) { if (!request.isSafe) { const token = getCookieValue(getMetaContent("csrf-param")) || getMetaContent("csrf-token"); if (token) { request.headers["X-CSRF-Token"] = token; } } if (this.requestAcceptsTurboStreamResponse(request)) { request.acceptResponseType(StreamMessage.contentType); } } requestStarted(_request) { this.state = FormSubmissionState.waiting; if (this.submitter) config.forms.submitter.beforeSubmit(this.submitter); this.setSubmitsWith(); markAsBusy(this.formElement); dispatch("turbo:submit-start", { target: this.formElement, detail: { formSubmission: this } }); this.delegate.formSubmissionStarted(this); } requestPreventedHandlingResponse(request, response) { prefetchCache.clear(); this.result = { success: response.succeeded, fetchResponse: response }; } requestSucceededWithResponse(request, response) { if (response.clientError || response.serverError) { this.delegate.formSubmissionFailedWithResponse(this, response); return } prefetchCache.clear(); if (this.requestMustRedirect(request) && responseSucceededWithoutRedirect(response)) { const error = new Error("Form responses must redirect to another location"); this.delegate.formSubmissionErrored(this, error); } else { this.state = FormSubmissionState.receiving; this.result = { success: true, fetchResponse: response }; this.delegate.formSubmissionSucceededWithResponse(this, response); } } requestFailedWithResponse(request, response) { this.result = { success: false, fetchResponse: response }; this.delegate.formSubmissionFailedWithResponse(this, response); } requestErrored(request, error) { this.result = { success: false, error }; this.delegate.formSubmissionErrored(this, error); } requestFinished(_request) { this.state = FormSubmissionState.stopped; if (this.submitter) config.forms.submitter.afterSubmit(this.submitter); this.resetSubmitterText(); clearBusyState(this.formElement); dispatch("turbo:submit-end", { target: this.formElement, detail: { formSubmission: this, ...this.result } }); this.delegate.formSubmissionFinished(this); } // Private setSubmitsWith() { if (!this.submitter || !this.submitsWith) return if (this.submitter.matches("button")) { this.originalSubmitText = this.submitter.innerHTML; this.submitter.innerHTML = this.submitsWith; } else if (this.submitter.matches("input")) { const input = this.submitter; this.originalSubmitText = input.value; input.value = this.submitsWith; } } resetSubmitterText() { if (!this.submitter || !this.originalSubmitText) return if (this.submitter.matches("button")) { this.submitter.innerHTML = this.originalSubmitText; } else if (this.submitter.matches("input")) { const input = this.submitter; input.value = this.originalSubmitText; } } requestMustRedirect(request) { return !request.isSafe && this.mustRedirect } requestAcceptsTurboStreamResponse(request) { return !request.isSafe || hasAttribute("data-turbo-stream", this.submitter, this.formElement) } get submitsWith() { return this.submitter?.getAttribute("data-turbo-submits-with") } } function buildFormData(formElement, submitter) { const formData = new FormData(formElement); const name = submitter?.getAttribute("name"); const value = submitter?.getAttribute("value"); if (name) { formData.append(name, value || ""); } return formData } function getCookieValue(cookieName) { if (cookieName != null) { const cookies = document.cookie ? document.cookie.split("; ") : []; const cookie = cookies.find((cookie) => cookie.startsWith(cookieName)); if (cookie) { const value = cookie.split("=").slice(1).join("="); return value ? decodeURIComponent(value) : undefined } } } function responseSucceededWithoutRedirect(response) { return response.statusCode == 200 && !response.redirected } function getFormAction(formElement, submitter) { const formElementAction = typeof formElement.action === "string" ? formElement.action : null; if (submitter?.hasAttribute("formaction")) { return submitter.getAttribute("formaction") || "" } else { return formElement.getAttribute("action") || formElementAction || "" } } function getAction(formAction, fetchMethod) { const action = expandURL(formAction); if (isSafe(fetchMethod)) { action.search = ""; } return action } function getMethod(formElement, submitter) { const method = submitter?.getAttribute("formmethod") || formElement.getAttribute("method") || ""; return fetchMethodFromString(method.toLowerCase()) || FetchMethod.get } function getEnctype(formElement, submitter) { return fetchEnctypeFromString(submitter?.getAttribute("formenctype") || formElement.enctype) } class Snapshot { constructor(element) { this.element = element; } get activeElement() { return this.element.ownerDocument.activeElement } get children() { return [...this.element.children] } hasAnchor(anchor) { return this.getElementForAnchor(anchor) != null } getElementForAnchor(anchor) { return anchor ? this.element.querySelector(`[id='${anchor}'], a[name='${anchor}']`) : null } get isConnected() { return this.element.isConnected } get firstAutofocusableElement() { return queryAutofocusableElement(this.element) } get permanentElements() { return queryPermanentElementsAll(this.element) } getPermanentElementById(id) { return getPermanentElementById(this.element, id) } getPermanentElementMapForSnapshot(snapshot) { const permanentElementMap = {}; for (const currentPermanentElement of this.permanentElements) { const { id } = currentPermanentElement; const newPermanentElement = snapshot.getPermanentElementById(id); if (newPermanentElement) { permanentElementMap[id] = [currentPermanentElement, newPermanentElement]; } } return permanentElementMap } } function getPermanentElementById(node, id) { return node.querySelector(`#${id}[data-turbo-permanent]`) } function queryPermanentElementsAll(node) { return node.querySelectorAll("[id][data-turbo-permanent]") } class FormSubmitObserver { started = false constructor(delegate, eventTarget) { this.delegate = delegate; this.eventTarget = eventTarget; } start() { if (!this.started) { this.eventTarget.addEventListener("submit", this.submitCaptured, true); this.started = true; } } stop() { if (this.started) { this.eventTarget.removeEventListener("submit", this.submitCaptured, true); this.started = false; } } submitCaptured = () => { this.eventTarget.removeEventListener("submit", this.submitBubbled, false); this.eventTarget.addEventListener("submit", this.submitBubbled, false); } submitBubbled = (event) => { if (!event.defaultPrevented) { const form = event.target instanceof HTMLFormElement ? event.target : undefined; const submitter = event.submitter || undefined; if ( form && submissionDoesNotDismissDialog(form, submitter) && submissionDoesNotTargetIFrame(form, submitter) && this.delegate.willSubmitForm(form, submitter) ) { event.preventDefault(); event.stopImmediatePropagation(); this.delegate.formSubmitted(form, submitter); } } } } function submissionDoesNotDismissDialog(form, submitter) { const method = submitter?.getAttribute("formmethod") || form.getAttribute("method"); return method != "dialog" } function submissionDoesNotTargetIFrame(form, submitter) { const target = submitter?.getAttribute("formtarget") || form.getAttribute("target"); return doesNotTargetIFrame(target) } class View { #resolveRenderPromise = (_value) => {} #resolveInterceptionPromise = (_value) => {} constructor(delegate, element) { this.delegate = delegate; this.element = element; } // Scrolling scrollToAnchor(anchor) { const element = this.snapshot.getElementForAnchor(anchor); if (element) { this.scrollToElement(element); this.focusElement(element); } else { this.scrollToPosition({ x: 0, y: 0 }); } } scrollToAnchorFromLocation(location) { this.scrollToAnchor(getAnchor(location)); } scrollToElement(element) { element.scrollIntoView(); } focusElement(element) { if (element instanceof HTMLElement) { if (element.hasAttribute("tabindex")) { element.focus(); } else { element.setAttribute("tabindex", "-1"); element.focus(); element.removeAttribute("tabindex"); } } } scrollToPosition({ x, y }) { this.scrollRoot.scrollTo(x, y); } scrollToTop() { this.scrollToPosition({ x: 0, y: 0 }); } get scrollRoot() { return window } // Rendering async render(renderer) { const { isPreview, shouldRender, willRender, newSnapshot: snapshot } = renderer; // A workaround to ignore tracked element mismatch reloads when performing // a promoted Visit from a frame navigation const shouldInvalidate = willRender; if (shouldRender) { try { this.renderPromise = new Promise((resolve) => (this.#resolveRenderPromise = resolve)); this.renderer = renderer; await this.prepareToRenderSnapshot(renderer); const renderInterception = new Promise((resolve) => (this.#resolveInterceptionPromise = resolve)); const options = { resume: this.#resolveInterceptionPromise, render: this.renderer.renderElement, renderMethod: this.renderer.renderMethod }; const immediateRender = this.delegate.allowsImmediateRender(snapshot, options); if (!immediateRender) await renderInterception; await this.renderSnapshot(renderer); this.delegate.viewRenderedSnapshot(snapshot, isPreview, this.renderer.renderMethod); this.delegate.preloadOnLoadLinksForView(this.element); this.finishRenderingSnapshot(renderer); } finally { delete this.renderer; this.#resolveRenderPromise(undefined); delete this.renderPromise; } } else if (shouldInvalidate) { this.invalidate(renderer.reloadReason); } } invalidate(reason) { this.delegate.viewInvalidated(reason); } async prepareToRenderSnapshot(renderer) { this.markAsPreview(renderer.isPreview); await renderer.prepareToRender(); } markAsPreview(isPreview) { if (isPreview) { this.element.setAttribute("data-turbo-preview", ""); } else { this.element.removeAttribute("data-turbo-preview"); } } markVisitDirection(direction) { this.element.setAttribute("data-turbo-visit-direction", direction); } unmarkVisitDirection() { this.element.removeAttribute("data-turbo-visit-direction"); } async renderSnapshot(renderer) { await renderer.render(); } finishRenderingSnapshot(renderer) { renderer.finishRendering(); } } class FrameView extends View { missing() { this.element.innerHTML = `<strong class="turbo-frame-error">Content missing</strong>`; } get snapshot() { return new Snapshot(this.element) } } class LinkInterceptor { constructor(delegate, element) { this.delegate = delegate; this.element = element; } start() { this.element.addEventListener("click", this.clickBubbled); document.addEventListener("turbo:click", this.linkClicked); document.addEventListener("turbo:before-visit", this.willVisit); } stop() { this.element.removeEventListener("click", this.clickBubbled); document.removeEventListener("turbo:click", this.linkClicked); document.removeEventListener("turbo:before-visit", this.willVisit); } clickBubbled = (event) => { if (this.clickEventIsSignificant(event)) { this.clickEvent = event; } else { delete this.clickEvent; } } linkClicked = (event) => { if (this.clickEvent && this.clickEventIsSignificant(event)) { if (this.delegate.shouldInterceptLinkClick(event.target, event.detail.url, event.detail.originalEvent)) { this.clickEvent.preventDefault(); event.preventDefault(); this.delegate.linkClickIntercepted(event.target, event.detail.url, event.detail.originalEvent); } } delete this.clickEvent; } willVisit = (_event) => { delete this.clickEvent; } clickEventIsSignificant(event) { const target = event.composed ? event.target?.parentElement : event.target; const element = findLinkFromClickTarget(target) || target; return element instanceof Element && element.closest("turbo-frame, html") == this.element } } class LinkClickObserver { started = false constructor(delegate, eventTarget) { this.delegate = delegate; this.eventTarget = eventTarget; } start() { if (!this.started) { this.eventTarget.addEventListener("click", this.clickCaptured, true); this.started = true; } } stop() { if (this.started) { this.eventTarget.removeEventListener("click", this.clickCaptured, true); this.started = false; } } clickCaptured = () => { this.eventTarget.removeEventListener("click", this.clickBubbled, false); this.eventTarget.addEventListener("click", this.clickBubbled, false); } clickBubbled = (event) => { if (event instanceof MouseEvent && this.clickEventIsSignificant(event)) { const target = (event.composedPath && event.composedPath()[0]) || event.target; const link = findLinkFromClickTarget(target); if (link && doesNotTargetIFrame(link.target)) { const location = getLocationForLink(link); if (this.delegate.willFollowLinkToLocation(link, location, event)) { event.preventDefault(); this.delegate.followedLinkToLocation(link, location); } } } } clickEventIsSignificant(event) { return !( (event.target && event.target.isContentEditable) || event.defaultPrevented || event.which > 1 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey ) } } class FormLinkClickObserver { constructor(delegate, element) { this.delegate = delegate; this.linkInterceptor = new LinkClickObserver(this, element); } start() { this.linkInterceptor.start(); } stop() { this.linkInterceptor.stop(); } // Link hover observer delegate canPrefetchRequestToLocation(link, location) { return false } prefetchAndCacheRequestToLocation(link, location) { return } // Link click observer delegate willFollowLinkToLocation(link, location, originalEvent) { return ( this.delegate.willSubmitFormLinkToLocation(link, location, originalEvent) && (link.hasAttribute("data-turbo-method") || link.hasAttribute("data-turbo-stream")) ) } followedLinkToLocation(link, location) { const form = document.createElement("form"); const type = "hidden"; for (const [name, value] of location.searchParams) { form.append(Object.assign(document.createElement("input"), { type, name, value })); } const action = Object.assign(location, { search: "" }); form.setAttribute("data-turbo", "true"); form.setAttribute("action", action.href); form.setAttribute("hidden", ""); const method = link.getAttribute("data-turbo-method"); if (method) form.setAttribute("method", method); const turboFrame = link.getAttribute("data-turbo-frame"); if (turboFrame) form.setAttribute("data-turbo-frame", turboFrame); const turboAction = getVisitAction(link); if (turboAction) form.setAttribute("data-turbo-action", turboAction); const turboConfirm = link.getAttribute("data-turbo-confirm"); if (turboConfirm) form.setAttribute("data-turbo-confirm", turboConfirm); const turboStream = link.hasAttribute("data-turbo-stream"); if (turboStream) form.setAttribute("data-turbo-stream", ""); this.delegate.submittedFormLinkToLocation(link, location, form); document.body.appendChild(form); form.addEventListener("turbo:submit-end", () => form.remove(), { once: true }); requestAnimationFrame(() => form.requestSubmit()); } } class Bardo { static async preservingPermanentElements(delegate, permanentElementMap, callback) { const bardo = new this(delegate, permanentElementMap); bardo.enter(); await callback(); bardo.leave(); } constructor(delegate, permanentElementMap) { this.delegate = delegate; this.permanentElementMap = permanentElementMap; } enter() { for (const id in this.permanentElementMap) { const [currentPermanentElement, newPermanentElement] = this.permanentElementMap[id]; this.delegate.enteringBardo(currentPermanentElement, newPermanentElement); this.replaceNewPermanentElementWithPlaceholder(newPermanentElement); } } leave() { for (const id in this.permanentElementMap) { const [currentPermanentElement] = this.permanentElementMap[id]; 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 static renderElement(currentElement, newElement) { // Abstract method } constructor(currentSnapshot, newSnapshot, isPreview, willRender = true) { this.currentSnapshot = currentSnapshot; this.newSnapshot = newSnapshot; this.isPreview = isPreview; this.willRender = willRender; this.renderElement = this.constructor.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 } } /** * @typedef {object} ConfigHead * * @property {'merge' | 'append' | 'morph' | 'none'} [style] * @property {boolean} [block] * @property {boolean} [ignore] * @property {function(Element): boolean} [shouldPreserve] * @property {function(Element): boolean} [shouldReAppend] * @property {function(Element): boolean} [shouldRemove] * @property {function(Element, {added: Node[], kept: Element[], removed: Element[]}): void} [afterHeadMorphed] */ /** * @typedef {object} ConfigCallbacks * * @property {function(Node): boolean} [beforeNodeAdded] * @property {function(Node): void} [afterNodeAdded] * @property {function(Element, Node): boolean} [beforeNodeMorphed] * @property {function(Element, Node): void} [afterNodeMorphed] * @property {function(Element): boolean} [beforeNodeRemoved] * @property {function(Element): void} [afterNodeRemoved] * @property {function(string, Element, "update" | "remove"): boolean} [beforeAttributeUpdated] */ /** * @typedef {object} Config * * @property {'outerHTML' | 'innerHTML'} [morphStyle] * @property {boolean} [ignoreActive] * @property {boolean} [ignoreActiveValue] * @property {boolean} [restoreFocus] * @property {ConfigCallbacks} [callbacks] * @property {ConfigHead} [head] */ /** * @typedef {function} NoOp * * @returns {void} */ /** * @typedef {object} ConfigHeadInternal * * @property {'merge' | 'append' | 'morph' | 'none'} style * @property {boolean} [block] * @property {boolean} [ignore] * @property {(function(Element): boolean) | NoOp} shouldPreserve * @property {(function(Element): boolean) | NoOp} shouldReAppend * @property {(function(Element): boolean) | NoOp} shouldRemove * @property {(function(Element, {added: Node[], kept: Element[], removed: Element[]}): void) | NoOp} afterHeadMorphed */ /** * @typedef {object} ConfigCallbacksInternal * * @property {(function(Node): boolean) | NoOp} beforeNodeAdded * @property {(function(Node): void) | NoOp} afterNodeAdded * @property {(function(Node, Node): boolean) | NoOp} beforeNodeMorphed * @property {(function(Node, Node): void) | NoOp} afterNodeMorphed * @property {(function(Node): boolean) | NoOp} beforeNodeRemoved * @property {(function(Node): void) | NoOp} afterNodeRemoved * @property {(function(string, Element, "update" | "remove"): boolean) | NoOp} beforeAttributeUpdated */ /** * @typedef {object} ConfigInternal * * @property {'outerHTML' | 'innerHTML'} morphStyle * @property {boolean} [ignoreActive] * @property {boolean} [ignoreActiveValue] * @property {boolean} [restoreFocus] * @property {ConfigCallbacksInternal} callbacks * @property {ConfigHeadInternal} head */ /** * @typedef {Object} IdSets * @property {Set<string>} persistentIds * @property {Map<Node, Set<string>>} idMap */ /** * @typedef {Function} Morph * * @param {Element | Document} oldNode * @param {Element | Node | HTMLCollection | Node[] | string | null} newContent * @param {Config} [config] * @returns {undefined | Node[]} */ // base IIFE to define idiomorph /** * * @type {{defaults: ConfigInternal, morph: Morph}} */ var Idiomorph = (function () { /** * @typedef {object} MorphContext * * @property {Element} target * @property {Element} newContent * @property {ConfigInternal} config * @property {ConfigInternal['morphStyle']} morphStyle * @property {ConfigInternal['ignoreActive']} ignoreActive * @property {ConfigInternal['ignoreActiveValue']} ignoreActiveValue * @property {ConfigInternal['restoreFocus']} restoreFocus * @property {Map<Node, Set<string>>} idMap * @property {Set<string>} persistentIds * @property {ConfigInternal['callbacks']} callbacks * @property {ConfigInternal['head']} head * @property {HTMLDivElement} pantry */ //============================================================================= // AND NOW IT BEGINS... //============================================================================= const noOp = () => {}; /** * Default configuration values, updatable by users now * @type {ConfigInternal} */ const defaults = { morphStyle: "outerHTML", callbacks: { beforeNodeAdded: noOp, afterNodeAdded: noOp, beforeNodeMorphed: noOp, afterNodeMorphed: noOp, beforeNodeRemoved: noOp, afterNodeRemoved: noOp, beforeAttributeUpdated: noOp, }, head: { style: "merge", shouldPreserve: (elt) => elt.getAttribute("im-preserve") === "true", shouldReAppend: (elt) => elt.getAttribute("im-re-append") === "true", shouldRemove: noOp, afterHeadMorphed: noOp, }, restoreFocus: true, }; /** * Core idiomorph function for morphing one DOM tree to another * * @param {Element | Document} oldNode * @param {Element | Node | HTMLCollection | Node[] | string | null} newContent * @param {Config} [config] * @returns {Promise<Node[]> | Node[]} */ function morph(oldNode, newContent, config = {}) { oldNode = normalizeElement(oldNode); const newNode = normalizeParent(newContent); const ctx = createMorphContext(oldNode, newNode, config); const morphedNodes = saveAndRestoreFocus(ctx, () => { return withHeadBlocking( ctx, oldNode, newNode, /** @param {MorphContext} ctx */ (ctx) => { if (ctx.morphStyle === "innerHTML") { morphChildren(ctx, oldNode, newNode); return Array.from(oldNode.childNodes); } else { return morphOuterHTML(ctx, oldNode, newNode); } }, ); }); ctx.pantry.remove(); return morphedNodes; } /** * Morph just the outerHTML of the oldNode to the newContent * We have to be careful because the oldNode could have siblings which need to be untouched * @param {MorphContext} ctx * @param {Element} oldNode * @param {Element} newNode * @returns {Node[]} */ function morphOuterHTML(ctx, oldNode, newNode) { const oldParent = normalizeParent(oldNode); // basis for calulating which nodes were morphed // since there may be unmorphed sibling nodes let childNodes = Array.from(oldParent.childNodes); const index = childNodes.indexOf(oldNode); // how many elements are to the right of the oldNode const rightMargin = childNodes.length - (index + 1); morphChildren( ctx, oldParent, newNode, // these two optional params are the secret sauce oldNode, // start point for iteration oldNode.nextSibling, // end point for iteration ); // return just the morphed nodes childNodes = Array.from(oldParent.childNodes); return childNodes.slice(index, childNodes.length - rightMargin); } /** * @param {MorphContext} ctx * @param {Function} fn * @returns {Promise<Node[]> | Node[]} */ function saveAndRestoreFocus(ctx, fn) { if (!ctx.config.restoreFocus) return fn(); let activeElement = /** @type {HTMLInputElement|HTMLTextAreaElement|null} */ ( document.activeElement ); // don't bother if the active element is not an input or textarea if ( !( activeElement instanceof HTMLInputElement || activeElement instanceof HTMLTextAreaElement ) ) { return fn(); } const { id: activeElementId, selectionStart, selectionEnd } = activeElement; const results = fn(); if (activeElementId && activeElementId !== document.activeElement?.id) { activeElement = ctx.target.querySelector(`#${activeElementId}`); activeElement?.focus(); } if (activeElement && !activeElement.selectionEnd && selectionEnd) { activeElement.setSelectionRange(selectionStart, selectionEnd); } return results; } const morphChildren = (function () { /** * 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: * - for each node in the new content: * - search self and siblings for an id set match, falling back to a soft match * - if match found * - remove any nodes up to the match: * - pantry persistent nodes * - delete the rest * - morph the match * - elsif no match found, and node is persistent * - find its match by querying the old root (future) and pantry (past) * - move it and its children here * - morph it * - else * - create a new node from scratch as a last result * * @param {MorphContext} ctx the merge context * @param {Element} oldParent the old content that we are merging the new content into * @param {Element} newParent the parent element of the new content * @param {Node|null} [insertionPoint] the point in the DOM we start morphing at (defaults to first child) * @param {Node|null} [endPoint] the point in the DOM we stop morphing at (defaults to after last child) */ function morphChildren( ctx, oldParent, newParent, insertionPoint = null, endPoint = null, ) { // normalize if ( oldParent instanceof HTMLTemplateElement && newParent instanceof HTMLTemplateElement ) { // @ts-ignore we can pretend the DocumentFragment is an Element oldParent = oldParent.content; // @ts-ignore ditto newParent = newParent.content; } insertionPoint ||= oldParent.firstChild; // run through all the new content for (const newChild of newParent.childNodes) { // once we reach the end of the old parent content skip to the end and insert the rest if (insertionPoint && insertionPoint != endPoint) { const bestMatch = findBestMatch( ctx, newChild, insertionPoint, endPoint, ); if (bestMatch) { // if the node to morph is not at the insertion point then remove/move up to it if (bestMatch !== insertionPoint) { removeNodesBetween(ctx, insertionPoint, bestMatch); } morphNode(bestMatch, newChild, ctx); insertionPoint = bestMatch.nextSibling; continue; } } // if the matching node is elsewhere in the original content if (newChild instanceof Element && ctx.persistentIds.has(newChild.id)) { // move it and all its children here and morph const movedChild = moveBeforeById( oldParent, newChild.id, insertionPoint, ctx, ); morphNode(movedChild, newChild, ctx); insertionPoint = movedChild.nextSibling; continue; } // last resort: insert the new node from scratch const insertedNode = createNode( oldParent, newChild, insertionPoint, ctx, ); // could be null if beforeNodeAdded prevented insertion if (insertedNode) { insertionPoint = insertedNode.nextSibling; } } // remove any remaining old nodes that didn't match up with new content while (insertionPoint && insertionPoint != endPoint) { const tempNode = insertionPoint; insertionPoint = insertionPoint.nextSibling; removeNode(ctx, tempNode); } } /** * This performs the action of inserting a new node while handling situations where the node contains * elements with persistent ids and possible state info we can still preserve by moving in and then morphing * * @param {Element} oldParent * @param {Node} newChild * @param {Node|null} insertionPoint * @param {MorphContext} ctx * @returns {Node|null} */ function createNode(oldParent, newChild, insertionPoint, ctx) { if (ctx.callbacks.beforeNodeAdded(newChild) === false) return null; if (ctx.idMap.has(newChild)) { // node has children with ids with possible state so create a dummy elt of same type and apply full morph algorithm const newEmptyChild = document.createElement( /** @type {Element} */ (newChild).tagName, ); oldParent.insertBefore(newEmptyChild, insertionPoint); morphNode(newEmptyChild, newChild, ctx); ctx.callbacks.afterNodeAdded(newEmptyChild); return newEmptyChild; } else { // optimisation: no id state to preserve so we can just insert a clone of the newChild and its descendants const newClonedChild = document.importNode(newChild, true); // importNode to not mutate newParent oldParent.insertBefore(newClonedChild, insertionPoint); ctx.callbacks.afterNodeAdded(newClonedChild); return newClonedChild; } } //============================================================================= // Matching Functions //============================================================================= const findBestMatch = (function () { /** * Scans forward from the startPoint to the endPoint looking for a match * for the node. It looks for an id set match first, then a soft match. * We abort softmatching if we find two future soft matches, to reduce churn. * @param {Node} node * @param {MorphContext} ctx * @param {Node | null} startPoint * @param {Node | null} endPoint * @returns {Node | null} */ function findBestMatch(ctx, node, startPoint, endPoint) { let softMatch = null; let nextSibling = node.nextSibling; let siblingSoftMatchCount = 0; let cursor = startPoint; while (cursor && cursor != endPoint) { // soft matching is a prerequisite for id set matching if (isSoftMatch(cursor, node)) { if (isIdSetMatch(ctx, cursor, node)) { return cursor; // found an id set match, we're done! } // we haven't yet saved a soft match fallback if (softMatch === null) { // the current soft match will hard match something else in the future, leave it if (!ctx.idMap.has(cursor)) { // save this as the fallback if we get through the loop without finding a hard match softMatch = cursor; } } } if ( softMatch === null && nextSibling && isSoftMatch(cursor, nextSibling) ) { // 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, block soft matching for this node to allow // future siblings to soft match. This is to reduce churn in the DOM when an element // is prepended. if (siblingSoftMatchCount >= 2) { softMatch = undefined; } } // if the current node contains active element, stop looking for better future matches, // because if one is found, this node will be moved to the pantry, reparenting it and thus losing focus if (cursor.contains(document.activeElement)) break; cursor = cursor.nextSibling; } return softMatch || null; } /** * * @param {MorphContext} ctx * @param {Node} oldNode * @param {Node} newNode * @returns {boolean} */ function isIdSetMatch(ctx, oldNode, newNode) { let oldSet = ctx.idMap.get(oldNode); let newSet = ctx.idMap.get(newNode); if (!newSet || !oldSet) return false; for (const id of oldSet) { // a potential match is an id in the new and old nodes that // has not already been merged into the DOM // But the newNode content we call this on has not been // merged yet and we don't allow duplicate IDs so it is simple if (newSet.has(id)) { return true; } } return false; } /** * * @param {Node} oldNode * @param {Node} newNode * @returns {boolean} */ function isSoftMatch(oldNode, newNode) { // ok to cast: if one is not element, `id` and `tagName` will be undefined and we'll just compare that. const oldElt = /** @type {Element} */ (oldNode); const newElt = /** @type {Element} */ (newNode); return ( oldElt.nodeType === newElt.nodeType && oldElt.tagName === newElt.tagName && // If oldElt has an `id` with possible state and it doesn't match newElt.id then avoid morphing. // We'll still match an anonymous node with an IDed newElt, though, because if it got this far, // its not persistent, and new nodes can't have any hidden state. (!oldElt.id || oldElt.id === newElt.id) ); } return findBestMatch; })(); //============================================================================= // DOM Manipulation Functions //============================================================================= /** * Gets rid of an unwanted DOM node; strategy depends on nature of its reuse: * - Persistent nodes will be moved to the pantry for later reuse * - Other nodes will have their hooks called, and then are removed * @param {MorphContext} ctx * @param {Node} node */ function removeNode(ctx, node) { // are we going to id set match this later? if (ctx.idMap.has(node)) { // skip callbacks and move to pantry moveBefore(ctx.pantry, node, null); } else { // remove for realsies if (ctx.callbacks.beforeNodeRemoved(node) === false) return; node.parentNode?.removeChild(node); ctx.callbacks.afterNodeRemoved(node); } } /** * Remove nodes between the start and end nodes * @param {MorphContext} ctx * @param {Node} startInclusive * @param {Node} endExclusive * @returns {Node|null} */ function removeNodesBetween(ctx, startInclusive, endExclusive) { /** @type {Node | null} */ let cursor = startInclusive; // remove nodes until the endExclusive node while (cursor && cursor !== endExclusive) { let tempNode = /** @type {Node} */ (cursor); cursor = cursor.nextSibling; removeNode(ctx, tempNode); } return cursor; } /** * Search for an element by id within the document and pantry, and move it using moveBefore. * * @param {Element} parentNode - The parent node to which the element will be moved. * @param {string} id - The ID of the element to be moved. * @param {Node | null} after - The reference node to insert the element before. * If `null`, the element is appended as the last child. * @param {MorphContext} ctx * @returns {Element} The found element */ function moveBeforeById(parentNode, id, after, ctx) { const target = /** @type {Element} - will always be found */ ( ctx.target.querySelector(`#${id}`) || ctx.pantry.querySelector(`#${id}`) ); removeElementFromAncestorsIdMaps(target, ctx); moveBefore(parentNode, target, after); return target; } /** * Removes an element from its ancestors' id maps. This is needed when an element is moved from the * "future" via `moveBeforeId`. Otherwise, its erstwhile ancestors could be mistakenly moved to the * pantry rather than being deleted, preventing their removal hooks from being called. * * @param {Element} element - element to remove from its ancestors' id maps * @param {MorphContext} ctx */ function removeElementFromAncestorsIdMaps(element, ctx) { const id = element.id; /** @ts-ignore - safe to loop in this way **/ while ((element = element.parentNode)) { let idSet = ctx.idMap.get(element); if (idSet) { idSet.delete(id); if (!idSet.size) { ctx.idMap.delete(element); } } } } /** * Moves an element before another element within the same parent. * Uses the proposed `moveBefore` API if available (and working), otherwise falls back to `insertBefore`. * This is essentialy a forward-compat wrapper. * * @param {Element} parentNode - The parent node containing the after element. * @param {Node} element - The element to be moved. * @param {Node | null} after - The reference node to insert `element` before. * If `null`, `element` is appended as the last child. */ function moveBefore(parentNode, element, after) { // @ts-ignore - use proposed moveBefore feature if (parentNode.moveBefore) { try { // @ts-ignore - use proposed moveBefore feature parentNode.moveBefore(element, after); } catch (e) { // fall back to insertBefore as some browsers may fail on moveBefore when trying to move Dom disconnected nodes to pantry parentNode.insertBefore(element, after); } } else { parentNode.insertBefore(element, after); } } return morphChildren; })(); //============================================================================= // Single Node Morphing Code //============================================================================= const morphNode = (function () { /** * @param {Node} oldNode root node to merge content into * @param {Node} newContent new content to merge * @param {MorphContext} ctx the merge context * @returns {Node | null} the element that ended up in the DOM */ function morphNode(oldNode, newContent, ctx) { if (ctx.ignoreActive && oldNode === document.activeElement) { // don't morph focused element return null; } 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" ) { // ok to cast: if newContent wasn't also a <head>, it would've got caught in the `!isSoftMatch` branch above handleHeadElement( oldNode, /** @type {HTMLHeadElement} */ (newContent), ctx, ); } else { morphAttributes(oldNode, newContent, ctx); if (!ignoreValueOfActiveElement(oldNode, ctx)) { // @ts-ignore newContent can be a node here because .firstChild will be null morphChildren(ctx, oldNode, newContent); } } ctx.callbacks.afterNodeMorphed(oldNode, newContent); return oldNode; } /** * syncs the oldNode to the newNode, copying over all attributes and * inner element state from the newNode to the oldNode * * @param {Node} oldNode the node to copy attributes & state to * @param {Node} newNode the node to copy attributes & state from * @param {MorphContext} ctx the merge context */ function morphAttributes(oldNode, newNode, ctx) { let type = newNode.nodeType; // if is an element type, sync the attributes from the // new node into the new node if (type === 1 /* element type */) { const oldElt = /** @type {Element} */ (oldNode); const newElt = /** @type {Element} */ (newNode); const oldAttributes = oldElt.attributes; const newAttributes = newElt.attributes; for (const newAttribute of newAttributes) { if (ignoreAttribute(newAttribute.name, oldElt, "update", ctx)) { continue; } if (oldElt.getAttribute(newAttribute.name) !== newAttribute.value) { oldElt.setAttribute(newAttribute.name, newAttribute.value); } } // iterate backwards to avoid skipping over items when a delete occurs for (let i = oldAttributes.length - 1; 0 <= i; i--) { const oldAttribute = oldAttributes[i]; // toAttributes is a live NamedNodeMap, so iteration+mutation is unsafe // e.g. custom element attribute callbacks can remove other attributes if (!oldAttribute) continue; if (!newElt.hasAttribute(oldAttribute.name)) { if (ignoreAttribute(oldAttribute.name, oldElt, "remove", ctx)) { continue; } oldElt.removeAttribute(oldAttribute.name); } } if (!ignoreValueOfActiveElement(oldElt, ctx)) { syncInputValue(oldElt, newElt, ctx); } } // sync text nodes if (type === 8 /* comment */ || type === 3 /* text */) { if (oldNode.nodeValue !== newNode.nodeValue) { oldNode.nodeValue = newNode.nodeValue; } } } /** * 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 {Element} oldElement the element to sync the input value to * @param {Element} newElement the element to sync the input value from * @param {MorphContext} ctx the merge context */ function syncInputValue(oldElement, newElement, ctx) { if ( oldElement instanceof HTMLInputElement && newElement instanceof HTMLInputElement && newElement.type !== "file" ) { let newValue = newElement.value; let oldValue = oldElement.value; // sync boolean attributes syncBooleanAttribute(oldElement, newElement, "checked", ctx); syncBooleanAttribute(oldElement, newElement, "disabled", ctx); if (!newElement.hasAttribute("value")) { if (!ignoreAttribute("value", oldElement, "remove", ctx)) { oldElement.value = ""; oldElement.removeAttribute("value"); } } else if (oldValue !== newValue) { if (!ignoreAttribute("value", oldElement, "update", ctx)) { oldElement.setAttribute("value", newValue); oldElement.value = newValue; } } // TODO: QUESTION(1cg): this used to only check `newElement` unlike the other branches -- why? // did I break something? } else if ( oldElement instanceof HTMLOptionElement && newElement instanceof HTMLOptionElement ) { syncBooleanAttribute(oldElement, newElement, "selected", ctx); } else if ( oldElement instanceof HTMLTextAreaElement && newElement instanceof HTMLTextAreaElement ) { let newValue = newElement.value; let oldValue = oldElement.value; if (ignoreAttribute("value", oldElement, "update", ctx)) { return; } if (newValue !== oldValue) { oldElement.value = newValue; } if ( oldElement.firstChild && oldElement.firstChild.nodeValue !== newValue ) { oldElement.firstChild.nodeValue = newValue; } } } /** * @param {Element} oldElement element to write the value to * @param {Element} newElement element to read the value from * @param {string} attributeName the attribute name * @param {MorphContext} ctx the merge context */ function syncBooleanAttribute(oldElement, newElement, attributeName, ctx) { // @ts-ignore this function is only used on boolean attrs that are reflected as dom properties const newLiveValue = newElement[attributeName], // @ts-ignore ditto oldLiveValue = oldElement[attributeName]; if (newLiveValue !== oldLiveValue) { const ignoreUpdate = ignoreAttribute( attributeName, oldElement, "update", ctx, ); if (!ignoreUpdate) { // update attribute's associated DOM property // @ts-ignore this function is only used on boolean attrs that are reflected as dom properties oldElement[attributeName] = newElement[attributeName]; } if (newLiveValue) { if (!ignoreUpdate) { // https://developer.mozilla.org/en-US/docs/Glossary/Boolean/HTML // this is the correct way to set a boolean attribute to "true" oldElement.setAttribute(attributeName, ""); } } else { if (!ignoreAttribute(attributeName, oldElement, "remove", ctx)) { oldElement.removeAttribute(attributeName); } } } } /** * @param {string} attr the attribute to be mutated * @param {Element} element the element that is going to be updated * @param {"update" | "remove"} updateType * @param {MorphContext} ctx the merge context * @returns {boolean} true if the attribute should be ignored, false otherwise */ function ignoreAttribute(attr, element, updateType, ctx) { if ( attr === "value" && ctx.ignoreActiveValue && element === document.activeElement ) { return true; } return ( ctx.callbacks.beforeAttributeUpdated(attr, element, updateType) === false ); } /** * @param {Node} possibleActiveElement * @param {MorphContext} ctx * @returns {boolean} */ function ignoreValueOfActiveElement(possibleActiveElement, ctx) { return ( !!ctx.ignoreActiveValue && possibleActiveElement === document.activeElement && possibleActiveElement !== document.body ); } return morphNode; })(); //============================================================================= // Head Management Functions //============================================================================= /** * @param {MorphContext} ctx * @param {Element} oldNode * @param {Element} newNode * @param {function} callback * @returns {Node[] | Promise<Node[]>} */ function withHeadBlocking(ctx, oldNode, newNode, callback) { if (ctx.head.block) { const oldHead = oldNode.querySelector("head"); const newHead = newNode.querySelector("head"); if (oldHead && newHead) { const promises = handleHeadElement(oldHead, newHead, ctx); // when head promises resolve, proceed ignoring the head tag return Promise.all(promises).then(() => { const newCtx = Object.assign(ctx, { head: { block: false, ignore: true, }, }); return callback(newCtx); }); } } // just proceed if we not head blocking return callback(ctx); } /** * The HEAD tag can be handled specially, either w/ a 'merge' or 'append' style * * @param {Element} oldHead * @param {Element} newHead * @param {MorphContext} ctx * @returns {Promise<void>[]} */ function handleHeadElement(oldHead, newHead, ctx) { let added = []; let removed = []; let preserved = []; let nodesToAppend = []; // put all new head elements into a Map, by their outerHTML let srcToNewHeadNodes = new Map(); for (const newHeadChild of newHead.children) { srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild); } // for each elt in the current head for (const currentHeadElt of oldHead.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 (ctx.head.style === "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) { // TODO: This could theoretically be null, based on type let newElt = /** @type {ChildNode} */ ( document.createRange().createContextualFragment(newNode.outerHTML) .firstChild ); if (ctx.callbacks.beforeNodeAdded(newElt) !== false) { if ( ("href" in newElt && newElt.href) || ("src" in newElt && newElt.src) ) { /** @type {(result?: any) => void} */ let resolve; let promise = new Promise(function (_resolve) { resolve = _resolve; }); newElt.addEventListener("load", function () { resolve(); }); promises.push(promise); } oldHead.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) { oldHead.removeChild(removedElement); ctx.callbacks.afterNodeRemoved(removedElement); } } ctx.head.afterHeadMorphed(oldHead, { added: added, kept: preserved, removed: removed, }); return promises; } //============================================================================= // Create Morph Context Functions //============================================================================= const createMorphContext = (function () { /** * * @param {Element} oldNode * @param {Element} newContent * @param {Config} config * @returns {MorphContext} */ function createMorphContext(oldNode, newContent, config) { const { persistentIds, idMap } = createIdMaps(oldNode, newContent); const mergedConfig = mergeDefaults(config); const morphStyle = mergedConfig.morphStyle || "outerHTML"; if (!["innerHTML", "outerHTML"].includes(morphStyle)) { throw `Do not understand how to morph style ${morphStyle}`; } return { target: oldNode, newContent: newContent, config: mergedConfig, morphStyle: morphStyle, ignoreActive: mergedConfig.ignoreActive, ignoreActiveValue: mergedConfig.ignoreActiveValue, restoreFocus: mergedConfig.restoreFocus, idMap: idMap, persistentIds: persistentIds, pantry: createPantry(), callbacks: mergedConfig.callbacks, head: mergedConfig.head, }; } /** * Deep merges the config object and the Idiomorph.defaults object to * produce a final configuration object * @param {Config} config * @returns {ConfigInternal} */ function mergeDefaults(config) { let finalConfig = Object.assign({}, defaults); // copy top level stuff into final config Object.assign(finalConfig, config); // copy callbacks into final config (do this to deep merge the callbacks) finalConfig.callbacks = Object.assign( {}, defaults.callbacks, config.callbacks, ); // copy head config into final config (do this to deep merge the head) finalConfig.head = Object.assign({}, defaults.head, config.head); return finalConfig; } /** * @returns {HTMLDivElement} */ function createPantry() { const pantry = document.createElement("div"); pantry.hidden = true; document.body.insertAdjacentElement("afterend", pantry); return pantry; } /** * Returns all elements with an ID contained within the root element and its descendants * * @param {Element} root * @returns {Element[]} */ function findIdElements(root) { let elements = Array.from(root.querySelectorAll("[id]")); if (root.id) { elements.push(root); } return elements; } /** * A bottom-up algorithm that populates a map of Element -> IdSet. * The idSet for a given element is the set of all IDs contained within its subtree. * As an optimzation, we filter these IDs through the given list of persistent IDs, * because we don't need to bother considering IDed elements that won't be in the new content. * * @param {Map<Node, Set<string>>} idMap * @param {Set<string>} persistentIds * @param {Element} root * @param {Element[]} elements */ function populateIdMapWithTree(idMap, persistentIds, root, elements) { for (const elt of elements) { if (persistentIds.has(elt.id)) { /** @type {Element|null} */ let current = elt; // walk up the parent hierarchy of that element, adding the id // of element to the parent's id set while (current) { 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); if (current === root) break; 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 {IdSets} */ function createIdMaps(oldContent, newContent) { const oldIdElements = findIdElements(oldContent); const newIdElements = findIdElements(newContent); const persistentIds = createPersistentIds(oldIdElements, newIdElements); /** @type {Map<Node, Set<string>>} */ let idMap = new Map(); populateIdMapWithTree(idMap, persistentIds, oldContent, oldIdElements); /** @ts-ignore - if newContent is a duck-typed parent, pass its single child node as the root to halt upwards iteration */ const newRoot = newContent.__idiomorphRoot || newContent; populateIdMapWithTree(idMap, persistentIds, newRoot, newIdElements); return { persistentIds, idMap }; } /** * This function computes the set of ids that persist between the two contents excluding duplicates * * @param {Element[]} oldIdElements * @param {Element[]} newIdElements * @returns {Set<string>} */ function createPersistentIds(oldIdElements, newIdElements) { let duplicateIds = new Set(); /** @type {Map<string, string>} */ let oldIdTagNameMap = new Map(); for (const { id, tagName } of oldIdElements) { if (oldIdTagNameMap.has(id)) { duplicateIds.add(id); } else { oldIdTagNameMap.set(id, tagName); } } let persistentIds = new Set(); for (const { id, tagName } of newIdElements) { if (persistentIds.has(id)) { duplicateIds.add(id); } else if (oldIdTagNameMap.get(id) === tagName) { persistentIds.add(id); } // skip if tag types mismatch because its not possible to morph one tag into another } for (const id of duplicateIds) { persistentIds.delete(id); } return persistentIds; } return createMorphContext; })(); //============================================================================= // HTML Normalization Functions //============================================================================= const { normalizeElement, normalizeParent } = (function () { /** @type {WeakSet<Node>} */ const generatedByIdiomorph = new WeakSet(); /** * * @param {Element | Document} content * @returns {Element} */ function normalizeElement(content) { if (content instanceof Document) { return content.documentElement; } else { return content; } } /** * * @param {null | string | Node | HTMLCollection | Node[] | Document & {generatedByIdiomorph:boolean}} newContent * @returns {Element} */ function normalizeParent(newContent) { if (newContent == null) { return document.createElement("div"); // dummy parent element } else if (typeof newContent === "string") { return normalizeParent(parseContent(newContent)); } else if ( generatedByIdiomorph.has(/** @type {Element} */ (newContent)) ) { // the template tag created by idiomorph parsing can serve as a dummy parent return /** @type {Element} */ (newContent); } else if (newContent instanceof Node) { if (newContent.parentNode) { // we can't use the parent directly because newContent may have siblings // that we don't want in the morph, and reparenting might be expensive (TODO is it?), // so we create a duck-typed parent node instead. return createDuckTypedParent(newContent); } else { // 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; } } /** * Creates a fake duck-typed parent element to wrap a single node, without actually reparenting it. * "If it walks like a duck, and quacks like a duck, then it must be a duck!" -- James Whitcomb Riley (1849–1916) * * @param {Node} newContent * @returns {Element} */ function createDuckTypedParent(newContent) { return /** @type {Element} */ ( /** @type {unknown} */ ({ childNodes: [newContent], /** @ts-ignore - cover your eyes for a minute, tsc */ querySelectorAll: (s) => { /** @ts-ignore */ const elements = newContent.querySelectorAll(s); /** @ts-ignore */ return newContent.matches(s) ? [newContent, ...elements] : elements; }, /** @ts-ignore */ insertBefore: (n, r) => newContent.parentNode.insertBefore(n, r), /** @ts-ignore */ moveBefore: (n, r) => newContent.parentNode.moveBefore(n, r), // for later use with populateIdMapWithTree to halt upwards iteration get __idiomorphRoot() { return newContent; }, }) ); } /** * * @param {string} newContent * @returns {Node | null | DocumentFragment} */ function parseContent(newContent) { let parser = new DOMParser(); // remove svgs to avoid false-positive matches on head, etc. let contentWithSvgsRemoved = newContent.replace( /<svg(\s[^>]*>|>)([\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>/)) { generatedByIdiomorph.add(content); return content; } else { // otherwise return the html element as the parent container let htmlElement = content.firstChild; if (htmlElement) { generatedByIdiomorph.add(htmlElement); } return htmlElement; } } 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( "<body><template>" + newContent + "</template></body>", "text/html", ); let content = /** @type {HTMLTemplateElement} */ ( responseDoc.body.querySelector("template") ).content; generatedByIdiomorph.add(content); return content; } } return { normalizeElement, normalizeParent }; })(); //============================================================================= // 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.childNodes, { 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); } async preservingPermanentElements(callback) { return await callback() } } 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; const cspNonce = getCspNonce(); if (cspNonce) { element.nonce = cspNonce; } return element } createProgressElement() { const element = document.createElement("div"); element.className = "turbo-progress-bar"; return element } } 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 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(); await new Promise((resolve) => { this.frame = document.visibilityState === "hidden" ? setTimeout(() => resolve(), 0) : requestAnimationFrame(() => resolve()); }); 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) {} // Link prefetching linkPrefetchingIsEnabledForLocation(location) { return true } // 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); } } // Link prefetching linkPrefetchingIsEnabledForLocation(location) { // Not all adapters implement linkPrefetchingIsEnabledForLocation if (typeof this.adapter.linkPrefetchingIsEnabledForLocation === "function") { return this.adapter.linkPrefetchingIsEnabledForLocation(location) } return true } // 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") } } 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)) frame.reload(); } 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]") } 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, 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, 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) enabled = true started = false #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); const isCurrentUrl = url === document.baseURI; if (!isRecentRequest && !this.navigator.currentVisit && isCurrentUrl) { 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) { console.warn( "Please replace `session.setProgressBarDelay(delay)` with `session.progressBarDelay = delay`. The function is deprecated and will be removed in a future version of Turbo.`" ); this.progressBarDelay = delay; } set progressBarDelay(delay) { config.drive.progressBarDelay = delay; } get progressBarDelay() { return config.drive.progressBarDelay } set drive(value) { config.drive.enabled = value; } get drive() { return config.drive.enabled } set formMode(value) { config.forms.mode = value; } get formMode() { return config.forms.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) && this.navigator.linkPrefetchingIsEnabledForLocation(location) ) } // 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 (config.forms.mode == "off") { return false } else { const submitterIsNavigatable = submitter ? this.elementIsNavigatable(submitter) : true; if (config.forms.mode == "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 (config.drive.enabled || 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) { console.warn( "Please replace `Turbo.setProgressBarDelay(delay)` with `Turbo.config.drive.progressBarDelay = delay`. The top-level function is deprecated and will be removed in a future version of Turbo.`" ); config.drive.progressBarDelay = delay; } function setConfirmMethod(confirmMethod) { console.warn( "Please replace `Turbo.setConfirmMethod(confirmMethod)` with `Turbo.config.forms.confirm = confirmMethod`. The top-level function is deprecated and will be removed in a future version of Turbo.`" ); config.forms.confirm = confirmMethod; } function setFormMode(mode) { console.warn( "Please replace `Turbo.setFormMode(mode)` with `Turbo.config.forms.mode = mode`. The top-level function is deprecated and will be removed in a future version of Turbo.`" ); config.forms.mode = mode; } var Turbo = /*#__PURE__*/Object.freeze({ __proto__: null, navigator: navigator$1, session: session, cache: cache, PageRenderer: PageRenderer, PageSnapshot: PageSnapshot, FrameRenderer: FrameRenderer, fetch: fetchWithTurboHeaders, config: config, 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() #shouldMorphFrame = false 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 { refresh, src } = this.element; this.#shouldMorphFrame = src && refresh === "morph"; 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.#shouldMorphFrame = false; 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); const rendererClass = this.#shouldMorphFrame ? MorphingFrameRenderer : FrameRenderer; if (newFrameElement) { const snapshot = new Snapshot(newFrameElement); const renderer = new rendererClass(this, this.view.snapshot, snapshot, 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 <turbo-frame id="${this.element.id}"> 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 <turbo-frame id="${this.element.id}"> 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 <turbo-frame id="${element.id}"> 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); } }; // <turbo-stream action=replace target=id><template>... /** * Renders updates to the page from a stream of messages. * * Using the `action` attribute, this can be configured one of eight ways: * * - `after` - inserts the result after the target * - `append` - appends the result to the target * - `before` - inserts the result before the target * - `prepend` - prepends the result to the target * - `refresh` - initiates a page refresh * - `remove` - removes the target * - `replace` - replaces the outer HTML of the target * - `update` - replaces the inner HTML of the target * * @customElement turbo-stream * @example * <turbo-stream action="append" target="dom_id"> * <template> * Content to append to target designated with the dom_id. * </template> * </turbo-stream> */ class StreamElement extends HTMLElement { static async renderElement(newElement) { await newElement.performAction(); } async connectedCallback() { try { await this.render(); } catch (error) { console.error(error); } finally { this.disconnect(); } } async render() { return (this.renderPromise ??= (async () => { const event = this.beforeRenderEvent; if (this.dispatchEvent(event)) { await nextRepaint(); await event.detail.render(this); } })()) } disconnect() { try { this.remove(); // eslint-disable-next-line no-empty } catch {} } /** * Removes duplicate children (by ID) */ removeDuplicateTargetChildren() { this.duplicateChildren.forEach((c) => c.remove()); } /** * Gets the list of duplicate children (i.e. those with the same ID) */ get duplicateChildren() { const existingChildren = this.targetElements.flatMap((e) => [...e.children]).filter((c) => !!c.getAttribute("id")); const newChildrenIds = [...(this.templateContent?.children || [])].filter((c) => !!c.getAttribute("id")).map((c) => c.getAttribute("id")); return existingChildren.filter((c) => newChildrenIds.includes(c.getAttribute("id"))) } /** * Gets the action function to be performed. */ get performAction() { if (this.action) { const actionFunction = StreamActions[this.action]; if (actionFunction) { return actionFunction } this.#raise("unknown action"); } this.#raise("action attribute is missing"); } /** * Gets the target elements which the template will be rendered to. */ get targetElements() { if (this.target) { return this.targetElementsById } else if (this.targets) { return this.targetElementsByQuery } else { this.#raise("target or targets attribute is missing"); } } /** * Gets the contents of the main `<template>`. */ get templateContent() { return this.templateElement.content.cloneNode(true) } /** * Gets the main `<template>` used for rendering */ get templateElement() { if (this.firstElementChild === null) { const template = this.ownerDocument.createElement("template"); this.appendChild(template); return template } else if (this.firstElementChild instanceof HTMLTemplateElement) { return this.firstElementChild } this.#raise("first child element must be a <template> element"); } /** * Gets the current action. */ get action() { return this.getAttribute("action") } /** * Gets the current target (an element ID) to which the result will * be rendered. */ get target() { return this.getAttribute("target") } /** * Gets the current "targets" selector (a CSS selector) */ get targets() { return this.getAttribute("targets") } /** * Reads the request-id attribute */ get requestId() { return this.getAttribute("request-id") } #raise(message) { throw new Error(`${this.description}: ${message}`) } get description() { return (this.outerHTML.match(/<[^>]+>/) ?? [])[0] ?? "<turbo-stream>" } get beforeRenderEvent() { return new CustomEvent("turbo:before-stream-render", { bubbles: true, cancelable: true, detail: { newStream: this, render: StreamElement.renderElement } }) } get targetElementsById() { const element = this.ownerDocument?.getElementById(this.target); if (element !== null) { return [element] } else { return [] } } get targetElementsByQuery() { const elements = this.ownerDocument?.querySelectorAll(this.targets); if (elements.length !== 0) { return Array.prototype.slice.call(elements) } else { return [] } } } class StreamSourceElement extends HTMLElement { streamSource = null connectedCallback() { this.streamSource = this.src.match(/^ws{1,2}:/) ? new WebSocket(this.src) : new EventSource(this.src); connectStreamSource(this.streamSource); } disconnectedCallback() { if (this.streamSource) { this.streamSource.close(); disconnectStreamSource(this.streamSource); } } get src() { return this.getAttribute("src") || "" } } FrameElement.delegateConstructor = FrameController; if (customElements.get("turbo-frame") === undefined) { customElements.define("turbo-frame", FrameElement); } if (customElements.get("turbo-stream") === undefined) { customElements.define("turbo-stream", StreamElement); } if (customElements.get("turbo-stream-source") === undefined) { customElements.define("turbo-stream-source", StreamSourceElement); } (() => { let element = document.currentScript; if (!element) return if (element.hasAttribute("data-turbo-suppress-warning")) return element = element.parentElement; while (element) { if (element == document.body) { return console.warn( unindent` You are loading Turbo from a <script> element inside the <body> element. This is probably not what you meant to do! Load your application’s JavaScript bundle inside the <head> element instead. <script> elements in <body> are evaluated with each page change. For more information, see: https://turbo.hotwired.dev/handbook/building#working-with-script-elements —— Suppress this warning by adding a "data-turbo-suppress-warning" attribute to: %s `, element.outerHTML ) } element = element.parentElement; } })(); window.Turbo = { ...Turbo, StreamActions }; start(); export { FetchEnctype, FetchMethod, FetchRequest, FetchResponse, FrameElement, FrameLoadingStyle, FrameRenderer, PageRenderer, PageSnapshot, StreamActions, StreamElement, StreamSourceElement, cache, clearCache, config, connectStreamSource, disconnectStreamSource, fetchWithTurboHeaders as fetch, fetchEnctypeFromString, fetchMethodFromString, isSafe, navigator$1 as navigator, registerAdapter, renderStreamMessage, session, setConfirmMethod, setFormMode, setProgressBarDelay, start, visit };