From 839b1a4f39112be754f3b8ef1d783f887f2d83d9 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 5 Dec 2024 16:36:24 +0100 Subject: [PATCH] Drop turbo dependency and rely on vanilla action cable --- Gemfile.lock | 4 - app/assets/javascripts/hotwire_spark.js | 10086 +++------------- app/assets/javascripts/hotwire_spark.min.js | 29 +- .../javascripts/hotwire_spark.min.js.map | 2 +- app/channels/hotwire_spark/channel.rb | 5 + .../hotwire_spark/channels/consumer.js | 3 + .../channels/monitoring_channel.js | 40 + app/javascript/hotwire_spark/index.js | 5 +- app/javascript/hotwire_spark/logger.js | 1 - .../hotwire_spark/reloaders/html_reloader.js | 9 +- .../reloaders/stimulus_reloader.js | 9 +- .../hotwire_spark/stream_actions/index.js | 3 - .../stream_actions/reload_css.js | 8 - .../stream_actions/reload_html.js | 6 - .../stream_actions/reload_stimulus.js | 8 - hotwire_spark.gemspec | 1 - .../action_cable/streams_channel.rb | 16 - lib/hotwire_spark/engine.rb | 1 - lib/hotwire_spark/installer.rb | 8 +- lib/hotwire_spark/middleware.rb | 6 - package.json | 1 - yarn.lock | 18 - 22 files changed, 1577 insertions(+), 8692 deletions(-) create mode 100644 app/channels/hotwire_spark/channel.rb create mode 100644 app/javascript/hotwire_spark/channels/consumer.js create mode 100644 app/javascript/hotwire_spark/channels/monitoring_channel.js delete mode 100644 app/javascript/hotwire_spark/stream_actions/index.js delete mode 100644 app/javascript/hotwire_spark/stream_actions/reload_css.js delete mode 100644 app/javascript/hotwire_spark/stream_actions/reload_html.js delete mode 100644 app/javascript/hotwire_spark/stream_actions/reload_stimulus.js delete mode 100644 lib/hotwire_spark/action_cable/streams_channel.rb diff --git a/Gemfile.lock b/Gemfile.lock index 6f64ebab..9cdb66de 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -4,7 +4,6 @@ PATH hotwire_spark (0.1.0) listen rails (>= 8.0.0) - turbo-rails zeitwerk GEM @@ -239,9 +238,6 @@ GEM stringio (3.1.2) thor (1.3.2) timeout (0.4.2) - turbo-rails (2.0.11) - actionpack (>= 6.0.0) - railties (>= 6.0.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (2.6.0) diff --git a/app/assets/javascripts/hotwire_spark.js b/app/assets/javascripts/hotwire_spark.js index 08ec30e7..889544d3 100644 --- a/app/assets/javascripts/hotwire_spark.js +++ b/app/assets/javascripts/hotwire_spark.js @@ -1,7888 +1,1444 @@ var HotwireSpark = (function () { 'use strict'; - /*! - Turbo 8.0.12 - Copyright © 2024 37signals LLC - */ - /** - * The MIT License (MIT) - * - * Copyright (c) 2019 Javan Makhmali - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - */ - - (function (prototype) { - if (typeof prototype.requestSubmit == "function") return - - prototype.requestSubmit = function (submitter) { - if (submitter) { - validateSubmitter(submitter, this); - submitter.click(); - } else { - submitter = document.createElement("input"); - submitter.type = "submit"; - submitter.hidden = true; - this.appendChild(submitter); - submitter.click(); - this.removeChild(submitter); - } - }; - - function validateSubmitter(submitter, form) { - submitter instanceof HTMLElement || raise(TypeError, "parameter 1 is not of type 'HTMLElement'"); - submitter.type == "submit" || raise(TypeError, "The specified element is not a submit button"); - submitter.form == form || - raise(DOMException, "The specified element is not owned by this form element", "NotFoundError"); - } + var adapters = { + logger: typeof console !== "undefined" ? console : undefined, + WebSocket: typeof WebSocket !== "undefined" ? WebSocket : undefined + }; - function raise(errorConstructor, message, name) { - throw new errorConstructor("Failed to execute 'requestSubmit' on 'HTMLFormElement': " + message + ".", name) + var logger = { + log(...messages) { + if (this.enabled) { + messages.push(Date.now()); + adapters.logger.log("[ActionCable]", ...messages); + } } - })(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 - } + const now = () => (new Date).getTime(); - function clickCaptured(event) { - const submitter = findSubmitterFromClickTarget(event.target); + const secondsSince = time => (now() - time) / 1e3; - if (submitter && submitter.form) { - submittersByForm.set(submitter.form, submitter); + class ConnectionMonitor { + constructor(connection) { + this.visibilityDidChange = this.visibilityDidChange.bind(this); + this.connection = connection; + this.reconnectAttempts = 0; } - } - - (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 + start() { + if (!this.isRunning()) { + this.startedAt = now(); + delete this.stoppedAt; + this.startPolling(); + addEventListener("visibilitychange", this.visibilityDidChange); + logger.log(`ConnectionMonitor started. stale threshold = ${this.constructor.staleThreshold} s`); } } - - addEventListener("click", clickCaptured, true); - - Object.defineProperty(prototype, "submitter", { - get() { - if (this.type == "submit" && this.target instanceof HTMLFormElement) { - return submittersByForm.get(this.target) - } + stop() { + if (this.isRunning()) { + this.stoppedAt = now(); + this.stopPolling(); + removeEventListener("visibilitychange", this.visibilityDidChange); + logger.log("ConnectionMonitor stopped"); } - }); - })(); - - const FrameLoadingStyle = { - eager: "eager", - lazy: "lazy" - }; - - /** - * Contains a fragment of HTML which is updated based on navigation within - * it (e.g. via links or form submissions). - * - * @customElement turbo-frame - * @example - * - * - * Show all expanded messages in this frame. - * - * - *
- * Show response from this form within this frame. - *
- *
- */ - class FrameElement extends HTMLElement { - static delegateConstructor = undefined - - loaded = Promise.resolve() - - static get observedAttributes() { - return ["disabled", "loading", "src"] } - - constructor() { - super(); - this.delegate = new FrameElement.delegateConstructor(this); + isRunning() { + return this.startedAt && !this.stoppedAt; } - - connectedCallback() { - this.delegate.connect(); + recordMessage() { + this.pingedAt = now(); } - - disconnectedCallback() { - this.delegate.disconnect(); + recordConnect() { + this.reconnectAttempts = 0; + delete this.disconnectedAt; + logger.log("ConnectionMonitor recorded connect"); } - - reload() { - return this.delegate.sourceURLReloaded() + recordDisconnect() { + this.disconnectedAt = now(); + logger.log("ConnectionMonitor recorded disconnect"); } - - attributeChangedCallback(name) { - if (name == "loading") { - this.delegate.loadingStyleChanged(); - } else if (name == "src") { - this.delegate.sourceURLChanged(); - } else if (name == "disabled") { - this.delegate.disabledChanged(); - } + startPolling() { + this.stopPolling(); + this.poll(); } - - /** - * Gets the URL to lazily load source HTML from - */ - get src() { - return this.getAttribute("src") + stopPolling() { + clearTimeout(this.pollTimeout); } - - /** - * Sets the URL to lazily load source HTML from - */ - set src(value) { - if (value) { - this.setAttribute("src", value); - } else { - this.removeAttribute("src"); + poll() { + this.pollTimeout = setTimeout((() => { + this.reconnectIfStale(); + this.poll(); + }), this.getPollInterval()); + } + getPollInterval() { + const {staleThreshold: staleThreshold, reconnectionBackoffRate: reconnectionBackoffRate} = this.constructor; + const backoff = Math.pow(1 + reconnectionBackoffRate, Math.min(this.reconnectAttempts, 10)); + const jitterMax = this.reconnectAttempts === 0 ? 1 : reconnectionBackoffRate; + const jitter = jitterMax * Math.random(); + return staleThreshold * 1e3 * backoff * (1 + jitter); + } + reconnectIfStale() { + if (this.connectionIsStale()) { + logger.log(`ConnectionMonitor detected stale connection. reconnectAttempts = ${this.reconnectAttempts}, time stale = ${secondsSince(this.refreshedAt)} s, stale threshold = ${this.constructor.staleThreshold} s`); + this.reconnectAttempts++; + if (this.disconnectedRecently()) { + logger.log(`ConnectionMonitor skipping reopening recent disconnect. time disconnected = ${secondsSince(this.disconnectedAt)} s`); + } else { + logger.log("ConnectionMonitor reopening"); + this.connection.reopen(); + } } } - - /** - * Gets the refresh mode for the frame. - */ - get refresh() { - return this.getAttribute("refresh") + get refreshedAt() { + return this.pingedAt ? this.pingedAt : this.startedAt; } - - /** - * Sets the refresh mode for the frame. - */ - set refresh(value) { - if (value) { - this.setAttribute("refresh", value); - } else { - this.removeAttribute("refresh"); + connectionIsStale() { + return secondsSince(this.refreshedAt) > this.constructor.staleThreshold; + } + disconnectedRecently() { + return this.disconnectedAt && secondsSince(this.disconnectedAt) < this.constructor.staleThreshold; + } + visibilityDidChange() { + if (document.visibilityState === "visible") { + setTimeout((() => { + if (this.connectionIsStale() || !this.connection.isOpen()) { + logger.log(`ConnectionMonitor reopening stale connection on visibilitychange. visibilityState = ${document.visibilityState}`); + this.connection.reopen(); + } + }), 200); } } + } - get shouldReloadWithMorph() { - return this.src && this.refresh === "morph" - } + ConnectionMonitor.staleThreshold = 6; - /** - * Determines if the element is loading - */ - get loading() { - return frameLoadingStyleFromString(this.getAttribute("loading") || "") - } + ConnectionMonitor.reconnectionBackoffRate = .15; + + var INTERNAL = { + message_types: { + welcome: "welcome", + disconnect: "disconnect", + ping: "ping", + confirmation: "confirm_subscription", + rejection: "reject_subscription" + }, + disconnect_reasons: { + unauthorized: "unauthorized", + invalid_request: "invalid_request", + server_restart: "server_restart", + remote: "remote" + }, + default_mount_path: "/cable", + protocols: [ "actioncable-v1-json", "actioncable-unsupported" ] + }; + + const {message_types: message_types, protocols: protocols} = INTERNAL; + + const supportedProtocols = protocols.slice(0, protocols.length - 1); + + const indexOf = [].indexOf; - /** - * Sets the value of if the element is loading - */ - set loading(value) { - if (value) { - this.setAttribute("loading", value); + class Connection { + constructor(consumer) { + this.open = this.open.bind(this); + this.consumer = consumer; + this.subscriptions = this.consumer.subscriptions; + this.monitor = new ConnectionMonitor(this); + this.disconnected = true; + } + send(data) { + if (this.isOpen()) { + this.webSocket.send(JSON.stringify(data)); + return true; } else { - this.removeAttribute("loading"); + return false; } } - - /** - * 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", ""); + open() { + if (this.isActive()) { + logger.log(`Attempted to open WebSocket, but existing socket is ${this.getState()}`); + return false; } else { - this.removeAttribute("disabled"); + const socketProtocols = [ ...protocols, ...this.consumer.subprotocols || [] ]; + logger.log(`Opening WebSocket, current state is ${this.getState()}, subprotocols: ${socketProtocols}`); + if (this.webSocket) { + this.uninstallEventHandlers(); + } + this.webSocket = new adapters.WebSocket(this.consumer.url, socketProtocols); + this.installEventHandlers(); + this.monitor.start(); + return true; } } - - /** - * 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") + close({allowReconnect: allowReconnect} = { + allowReconnect: true + }) { + if (!allowReconnect) { + this.monitor.stop(); + } + if (this.isOpen()) { + return this.webSocket.close(); + } } - - /** - * 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", ""); + reopen() { + logger.log(`Reopening WebSocket, current state is ${this.getState()}`); + if (this.isActive()) { + try { + return this.close(); + } catch (error) { + logger.log("Failed to reopen WebSocket", error); + } finally { + logger.log(`Reopening WebSocket in ${this.constructor.reopenDelay}ms`); + setTimeout(this.open, this.constructor.reopenDelay); + } } else { - this.removeAttribute("autoscroll"); + return this.open(); } } - - /** - * Determines if the element has finished loading - */ - get complete() { - return !this.delegate.isLoading + getProtocol() { + if (this.webSocket) { + return this.webSocket.protocol; + } } - - /** - * Gets the active state of the frame. - * - * If inactive, source changes will not be observed. - */ - get isActive() { - return this.ownerDocument === document && !this.isPreview + isOpen() { + return this.isState("open"); } - - /** - * 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") + isActive() { + return this.isState("open", "connecting"); } - } - - function frameLoadingStyleFromString(style) { - switch (style.toLowerCase()) { - case "lazy": - return FrameLoadingStyle.lazy - default: - return FrameLoadingStyle.eager + triedToReconnect() { + return this.monitor.reconnectAttempts > 0; } - } - - 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 + isProtocolSupported() { + return indexOf.call(supportedProtocols, this.getProtocol()) >= 0; } - } - - function copyElementAttributes(destinationElement, sourceElement) { - for (const { name, value } of sourceElement.attributes) { - destinationElement.setAttribute(name, value); + isState(...states) { + return indexOf.call(states, this.getState()) >= 0; } - } - - 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); + getState() { + if (this.webSocket) { + for (let state in adapters.WebSocket) { + if (adapters.WebSocket[state] === this.webSocket.readyState) { + return state.toLowerCase(); + } + } + } + return null; } - - return event - } - - function cancelEvent(event) { - event.preventDefault(); - event.stopImmediatePropagation(); - } - - function nextRepaint() { - if (document.visibilityState === "hidden") { - return nextEventLoopTick() - } else { - return nextAnimationFrame() + installEventHandlers() { + for (let eventName in this.events) { + const handler = this.events[eventName].bind(this); + this.webSocket[`on${eventName}`] = handler; + } + } + uninstallEventHandlers() { + for (let eventName in this.events) { + this.webSocket[`on${eventName}`] = function() {}; + } } } - function nextAnimationFrame() { - return new Promise((resolve) => requestAnimationFrame(() => resolve())) - } - - function nextEventLoopTick() { - return new Promise((resolve) => setTimeout(() => resolve(), 0)) - } - - function nextMicrotask() { - return Promise.resolve() - } + Connection.reopenDelay = 500; - function parseHTMLDocument(html = "") { - return new DOMParser().parseFromString(html, "text/html") - } + Connection.prototype.events = { + message(event) { + if (!this.isProtocolSupported()) { + return; + } + const {identifier: identifier, message: message, reason: reason, reconnect: reconnect, type: type} = JSON.parse(event.data); + this.monitor.recordMessage(); + switch (type) { + case message_types.welcome: + if (this.triedToReconnect()) { + this.reconnectAttempted = true; + } + this.monitor.recordConnect(); + return this.subscriptions.reload(); - 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") - } + case message_types.disconnect: + logger.log(`Disconnecting. Reason: ${reason}`); + return this.close({ + allowReconnect: reconnect + }); - function interpolate(strings, values) { - return strings.reduce((result, string, i) => { - const value = values[i] == undefined ? "" : values[i]; - return result + string + value - }, "") - } + case message_types.ping: + return null; - 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) + case message_types.confirmation: + this.subscriptions.confirmSubscription(identifier); + if (this.reconnectAttempted) { + this.reconnectAttempted = false; + return this.subscriptions.notify(identifier, "connected", { + reconnected: true + }); } else { - return Math.floor(Math.random() * 15).toString(16) + return this.subscriptions.notify(identifier, "connected", { + reconnected: false + }); } - }) - .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)) - } + case message_types.rejection: + return this.subscriptions.reject(identifier); - function markAsBusy(...elements) { - for (const element of elements) { - if (element.localName == "turbo-frame") { - element.setAttribute("busy", ""); + default: + return this.subscriptions.notify(identifier, "received", message); } - element.setAttribute("aria-busy", "true"); - } - } - - function clearBusyState(...elements) { - for (const element of elements) { - if (element.localName == "turbo-frame") { - element.removeAttribute("busy"); + }, + open() { + logger.log(`WebSocket onopen event, using '${this.getProtocol()}' subprotocol`); + this.disconnected = false; + if (!this.isProtocolSupported()) { + logger.log("Protocol is unsupported. Stopping monitor and disconnecting."); + return this.close({ + allowReconnect: false + }); } - - element.removeAttribute("aria-busy"); + }, + close(event) { + logger.log("WebSocket onclose event"); + if (this.disconnected) { + return; + } + this.disconnected = true; + this.monitor.recordDisconnect(); + return this.subscriptions.notifyAll("disconnected", { + willAttemptReconnect: this.monitor.isRunning() + }); + }, + error() { + logger.log("WebSocket onerror event"); } - } - - 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 + const extend$1 = function(object, properties) { + if (properties != null) { + for (let key in properties) { + const value = properties[key]; + object[key] = value; + } } - } - - 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"); + return object; + }; - if (element) { - const { nonce, content } = element; - return nonce == "" ? content : nonce + class Subscription { + constructor(consumer, params = {}, mixin) { + this.consumer = consumer; + this.identifier = JSON.stringify(params); + extend$1(this, mixin); } - } - - function setMetaContent(name, content) { - let element = getMetaElement(name); - - if (!element) { - element = document.createElement("meta"); - element.setAttribute("name", name); - - document.head.appendChild(element); + perform(action, data = {}) { + data.action = action; + return this.send(data); } - - 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) - ) + send(data) { + return this.consumer.send({ + command: "message", + identifier: this.identifier, + data: JSON.stringify(data) + }); } - } - - 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 + unsubscribe() { + return this.consumer.subscriptions.remove(this); } } - function findLinkFromClickTarget(target) { - return findClosestRecursively(target, "a[href]:not([target^=_]):not([download])") - } - - function getLocationForLink(link) { - return expandURL(link.getAttribute("href") || "") - } - - function debounce(fn, delay) { - let timeoutId = null; - - return (...args) => { - const callback = () => fn.apply(this, args); - clearTimeout(timeoutId); - timeoutId = setTimeout(callback, delay); + class SubscriptionGuarantor { + constructor(subscriptions) { + this.subscriptions = subscriptions; + this.pendingSubscriptions = []; } - } - - 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); + guarantee(subscription) { + if (this.pendingSubscriptions.indexOf(subscription) == -1) { + logger.log(`SubscriptionGuarantor guaranteeing ${subscription.identifier}`); + this.pendingSubscriptions.push(subscription); + } else { + logger.log(`SubscriptionGuarantor already guaranteeing ${subscription.identifier}`); } - }, - - "disabled": { - beforeSubmit: submitter => submitter.disabled = true, - afterSubmit: submitter => submitter.disabled = false + this.startGuaranteeing(); } - }; - - class Config { - #submitter = null - - constructor(config) { - Object.assign(this, config); + forget(subscription) { + logger.log(`SubscriptionGuarantor forgetting ${subscription.identifier}`); + this.pendingSubscriptions = this.pendingSubscriptions.filter((s => s !== subscription)); } - - get submitter() { - return this.#submitter + startGuaranteeing() { + this.stopGuaranteeing(); + this.retrySubscribing(); } - - set submitter(value) { - this.#submitter = submitter[value] || value; + stopGuaranteeing() { + clearTimeout(this.retryTimeout); } - } - - 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] + retrySubscribing() { + this.retryTimeout = setTimeout((() => { + if (this.subscriptions && typeof this.subscriptions.subscribe === "function") { + this.pendingSubscriptions.map((subscription => { + logger.log(`SubscriptionGuarantor resubscribing ${subscription.identifier}`); + this.subscriptions.subscribe(subscription); + })); + } + }), 500); } } - 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 + class Subscriptions { + constructor(consumer) { + this.consumer = consumer; + this.guarantor = new SubscriptionGuarantor(this); + this.subscriptions = []; } - - get clientError() { - return this.statusCode >= 400 && this.statusCode <= 499 + create(channelName, mixin) { + const channel = channelName; + const params = typeof channel === "object" ? channel : { + channel: channel + }; + const subscription = new Subscription(this.consumer, params, mixin); + return this.add(subscription); } - - get serverError() { - return this.statusCode >= 500 && this.statusCode <= 599 + add(subscription) { + this.subscriptions.push(subscription); + this.consumer.ensureActiveConnection(); + this.notify(subscription, "initialized"); + this.subscribe(subscription); + return subscription; } - - get redirected() { - return this.response.redirected + remove(subscription) { + this.forget(subscription); + if (!this.findAll(subscription.identifier).length) { + this.sendCommand(subscription, "unsubscribe"); + } + return subscription; } - - get location() { - return expandURL(this.response.url) + reject(identifier) { + return this.findAll(identifier).map((subscription => { + this.forget(subscription); + this.notify(subscription, "rejected"); + return subscription; + })); } - - get isHTML() { - return this.contentType && this.contentType.match(/^(?:text\/([^\s;,]+\b)?html|application\/xhtml\+xml)\b/) + forget(subscription) { + this.guarantor.forget(subscription); + this.subscriptions = this.subscriptions.filter((s => s !== subscription)); + return subscription; } - - get statusCode() { - return this.response.status + findAll(identifier) { + return this.subscriptions.filter((s => s.identifier === identifier)); } - - get contentType() { - return this.header("Content-Type") + reload() { + return this.subscriptions.map((subscription => this.subscribe(subscription))); } - - get responseText() { - return this.response.clone().text() + notifyAll(callbackName, ...args) { + return this.subscriptions.map((subscription => this.notify(subscription, callbackName, ...args))); } - - get responseHTML() { - if (this.isHTML) { - return this.response.clone().text() + notify(subscription, callbackName, ...args) { + let subscriptions; + if (typeof subscription === "string") { + subscriptions = this.findAll(subscription); } else { - return Promise.resolve(undefined) + subscriptions = [ subscription ]; } + return subscriptions.map((subscription => typeof subscription[callbackName] === "function" ? subscription[callbackName](...args) : 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); + subscribe(subscription) { + if (this.sendCommand(subscription, "subscribe")) { + this.guarantor.guarantee(subscription); } - 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 + confirmSubscription(identifier) { + logger.log(`Subscription confirmed ${identifier}`); + this.findAll(identifier).map((subscription => this.guarantor.forget(subscription))); } - } - - 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 + sendCommand(subscription, command) { + const {identifier: identifier} = subscription; + return this.consumer.send({ + command: command, + identifier: identifier + }); } } - 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; + class Consumer { + constructor(url) { + this._url = url; + this.subscriptions = new Subscriptions(this); + this.connection = new Connection(this); + this.subprotocols = []; } - - get method() { - return this.fetchOptions.method + get url() { + return createWebSocketURL(this._url); } - - 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(); + send(data) { + return this.connection.send(data); } - - get headers() { - return this.fetchOptions.headers + connect() { + return this.connection.open(); } - - set headers(value) { - this.fetchOptions.headers = value; + disconnect() { + return this.connection.close({ + allowReconnect: false + }); } - - get body() { - if (this.isSafe) { - return this.url.searchParams - } else { - return this.fetchOptions.body + ensureActiveConnection() { + if (!this.connection.isActive()) { + return this.connection.open(); } } - - set body(value) { - this.fetchOptions.body = value; - } - - get location() { - return this.url - } - - get params() { - return this.url.searchParams + addSubProtocol(subprotocol) { + this.subprotocols = [ ...this.subprotocols, subprotocol ]; } + } - get entries() { - return this.body ? Array.from(this.body.entries()) : [] + function createWebSocketURL(url) { + if (typeof url === "function") { + url = url(); } - - cancel() { - this.abortController.abort(); + if (url && !/^wss?:/i.test(url)) { + const a = document.createElement("a"); + a.href = url; + a.href = a.href; + a.protocol = a.protocol.replace("http", "ws"); + return a.href; + } else { + return url; } + } - 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); - } + function createConsumer(url = getConfig("url") || INTERNAL.default_mount_path) { + return new Consumer(url); + } - 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); - } + function getConfig(name) { + const element = document.head.querySelector(`meta[name='action-cable-${name}']`); + if (element) { + return element.getAttribute("content"); } + } - 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 - } + var consumer = createConsumer(); - 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 nameFromFilePath(path) { + return path.split("/").pop().split(".")[0]; } - - function isSafe(fetchMethod) { - return fetchMethodFromString(fetchMethod) == FetchMethod.get + function urlWithParams(urlString, params) { + const url = new URL(urlString, window.location.origin); + Object.entries(params).forEach(_ref => { + let [key, value] = _ref; + url.searchParams.set(key, value); + }); + return url.toString(); } - - 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 cacheBustedUrl(urlString) { + return urlWithParams(urlString, { + reload: Date.now() + }); } - - function entriesExcludingFiles(requestBody) { - const entries = []; - - for (const [name, value] of requestBody) { - if (value instanceof File) continue - else entries.push([name, value]); - } - - return entries + async function reloadHtmlDocument() { + let currentUrl = urlWithParams(window.location.href, { + hotwire_spark: "true" + }); + const response = await fetch(currentUrl); + const fetchedHTML = await response.text(); + const parser = new DOMParser(); + return parser.parseFromString(fetchedHTML, "text/html"); } - function mergeIntoURLSearchParams(url, requestBody) { - const searchParams = new URLSearchParams(entriesExcludingFiles(requestBody)); - - url.search = searchParams.toString(); - - return url - } + // base IIFE to define idiomorph + var Idiomorph = (function () { - class AppearanceObserver { - started = false + //============================================================================= + // AND NOW IT BEGINS... + //============================================================================= + let EMPTY_SET = new Set(); - constructor(delegate, element) { - this.delegate = delegate; - this.element = element; - this.intersectionObserver = new IntersectionObserver(this.intersect); - } + // default configuration values, updatable by users now + let defaults = { + morphStyle: "outerHTML", + callbacks : { + beforeNodeAdded: noOp, + afterNodeAdded: noOp, + beforeNodeMorphed: noOp, + afterNodeMorphed: noOp, + beforeNodeRemoved: noOp, + afterNodeRemoved: noOp, + beforeAttributeUpdated: noOp, - start() { - if (!this.started) { - this.started = true; - this.intersectionObserver.observe(this.element); - } - } + }, + head: { + style: 'merge', + shouldPreserve: function (elt) { + return elt.getAttribute("im-preserve") === "true"; + }, + shouldReAppend: function (elt) { + return elt.getAttribute("im-re-append") === "true"; + }, + shouldRemove: noOp, + afterHeadMorphed: noOp, + } + }; - stop() { - if (this.started) { - this.started = false; - this.intersectionObserver.unobserve(this.element); - } - } + //============================================================================= + // Core Morphing Algorithm - morph, morphNormalizedContent, morphOldNodeTo, morphChildren + //============================================================================= + function morph(oldNode, newContent, config = {}) { - intersect = (entries) => { - const lastEntry = entries.slice(-1)[0]; - if (lastEntry?.isIntersecting) { - this.delegate.elementAppearedInViewport(this.element); - } - } - } + if (oldNode instanceof Document) { + oldNode = oldNode.documentElement; + } - class StreamMessage { - static contentType = "text/vnd.turbo-stream.html" + if (typeof newContent === 'string') { + newContent = parseContent(newContent); + } - static wrap(message) { - if (typeof message == "string") { - return new this(createDocumentFragment(message)) - } else { - return message - } - } + let normalizedContent = normalizeContent(newContent); - constructor(fragment) { - this.fragment = importStreamElements(fragment); - } - } + let ctx = createMorphContext(oldNode, normalizedContent, config); - function importStreamElements(fragment) { - for (const element of fragment.querySelectorAll("turbo-stream")) { - const streamElement = document.importNode(element, true); + return morphNormalizedContent(oldNode, normalizedContent, ctx); + } - for (const inertScriptElement of streamElement.templateElement.content.querySelectorAll("script")) { - inertScriptElement.replaceWith(activateScriptElement(inertScriptElement)); - } + function morphNormalizedContent(oldNode, normalizedNewContent, ctx) { + if (ctx.head.block) { + let oldHead = oldNode.querySelector('head'); + let newHead = normalizedNewContent.querySelector('head'); + if (oldHead && newHead) { + let promises = handleHeadElement(newHead, oldHead, ctx); + // when head promises resolve, call morph again, ignoring the head tag + Promise.all(promises).then(function () { + morphNormalizedContent(oldNode, normalizedNewContent, Object.assign(ctx, { + head: { + block: false, + ignore: true + } + })); + }); + return; + } + } - element.replaceWith(streamElement); - } + if (ctx.morphStyle === "innerHTML") { - return fragment - } + // innerHTML, so we are only updating the children + morphChildren(normalizedNewContent, oldNode, ctx); + return oldNode.children; - const PREFETCH_DELAY = 100; + } else if (ctx.morphStyle === "outerHTML" || ctx.morphStyle == null) { + // otherwise find the best element match in the new content, morph that, and merge its siblings + // into either side of the best match + let bestMatch = findBestNodeMatch(normalizedNewContent, oldNode, ctx); - class PrefetchCache { - #prefetchTimeout = null - #prefetched = null + // stash the siblings that will need to be inserted on either side of the best match + let previousSibling = bestMatch?.previousSibling; + let nextSibling = bestMatch?.nextSibling; - get(url) { - if (this.#prefetched && this.#prefetched.url === url && this.#prefetched.expire > Date.now()) { - return this.#prefetched.request - } - } + // morph it + let morphedNode = morphOldNodeTo(oldNode, bestMatch, ctx); - setLater(url, request, ttl) { - this.clear(); + if (bestMatch) { + // if there was a best match, merge the siblings in too and return the + // whole bunch + return insertSiblings(previousSibling, morphedNode, nextSibling); + } else { + // otherwise nothing was added to the DOM + return [] + } + } else { + throw "Do not understand how to morph style " + ctx.morphStyle; + } + } - 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) }; - } + /** + * @param possibleActiveElement + * @param ctx + * @returns {boolean} + */ + function ignoreValueOfActiveElement(possibleActiveElement, ctx) { + return ctx.ignoreActiveValue && possibleActiveElement === document.activeElement; + } - clear() { - if (this.#prefetchTimeout) clearTimeout(this.#prefetchTimeout); - this.#prefetched = null; - } - } + /** + * @param oldNode root node to merge content into + * @param newContent new content to merge + * @param ctx the merge context + * @returns {Element} the element that ended up in the DOM + */ + function morphOldNodeTo(oldNode, newContent, ctx) { + if (ctx.ignoreActive && oldNode === document.activeElement) ; else if (newContent == null) { + if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode; - const cacheTtl = 10 * 1000; - const prefetchCache = new PrefetchCache(); + oldNode.remove(); + ctx.callbacks.afterNodeRemoved(oldNode); + return null; + } else if (!isSoftMatch(oldNode, newContent)) { + if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode; + if (ctx.callbacks.beforeNodeAdded(newContent) === false) return oldNode; - const FormSubmissionState = { - initialized: "initialized", - requesting: "requesting", - waiting: "waiting", - receiving: "receiving", - stopping: "stopping", - stopped: "stopped" - }; + oldNode.parentElement.replaceChild(newContent, oldNode); + ctx.callbacks.afterNodeAdded(newContent); + ctx.callbacks.afterNodeRemoved(oldNode); + return newContent; + } else { + if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) return oldNode; - class FormSubmission { - state = FormSubmissionState.initialized + if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) ; else if (oldNode instanceof HTMLHeadElement && ctx.head.style !== "morph") { + handleHeadElement(newContent, oldNode, ctx); + } else { + syncNodeFrom(newContent, oldNode, ctx); + if (!ignoreValueOfActiveElement(oldNode, ctx)) { + morphChildren(newContent, oldNode, ctx); + } + } + ctx.callbacks.afterNodeMorphed(oldNode, newContent); + return oldNode; + } + } - static confirmMethod(message) { - return Promise.resolve(confirm(message)) - } + /** + * This is the core algorithm for matching up children. The idea is to use id sets to try to match up + * nodes as faithfully as possible. We greedily match, which allows us to keep the algorithm fast, but + * by using id sets, we are able to better match up with content deeper in the DOM. + * + * Basic algorithm is, for each node in the new content: + * + * - if we have reached the end of the old parent, append the new content + * - if the new content has an id set match with the current insertion point, morph + * - search for an id set match + * - if id set match found, morph + * - otherwise search for a "soft" match + * - if a soft match is found, morph + * - otherwise, prepend the new node before the current insertion point + * + * The two search algorithms terminate if competing node matches appear to outweigh what can be achieved + * with the current node. See findIdSetMatch() and findSoftMatch() for details. + * + * @param {Element} newParent the parent element of the new content + * @param {Element } oldParent the old content that we are merging the new content into + * @param ctx the merge context + */ + function morphChildren(newParent, oldParent, ctx) { - 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; - } + let nextNewChild = newParent.firstChild; + let insertionPoint = oldParent.firstChild; + let newChild; - get method() { - return this.fetchRequest.method - } + // run through all the new content + while (nextNewChild) { - set method(value) { - this.fetchRequest.method = value; - } + newChild = nextNewChild; + nextNewChild = newChild.nextSibling; - get action() { - return this.fetchRequest.url.toString() - } + // if we are at the end of the exiting parent's children, just append + if (insertionPoint == null) { + if (ctx.callbacks.beforeNodeAdded(newChild) === false) return; - set action(value) { - this.fetchRequest.url = expandURL(value); - } + oldParent.appendChild(newChild); + ctx.callbacks.afterNodeAdded(newChild); + removeIdsFromConsideration(ctx, newChild); + continue; + } - get body() { - return this.fetchRequest.body - } + // if the current node has an id set match then morph + if (isIdSetMatch(newChild, insertionPoint, ctx)) { + morphOldNodeTo(insertionPoint, newChild, ctx); + insertionPoint = insertionPoint.nextSibling; + removeIdsFromConsideration(ctx, newChild); + continue; + } - get enctype() { - return this.fetchRequest.enctype - } + // otherwise search forward in the existing old children for an id set match + let idSetMatch = findIdSetMatch(newParent, oldParent, newChild, insertionPoint, ctx); - get isSafe() { - return this.fetchRequest.isSafe - } + // if we found a potential match, remove the nodes until that point and morph + if (idSetMatch) { + insertionPoint = removeNodesBetween(insertionPoint, idSetMatch, ctx); + morphOldNodeTo(idSetMatch, newChild, ctx); + removeIdsFromConsideration(ctx, newChild); + continue; + } - get location() { - return this.fetchRequest.url - } + // no id set match found, so scan forward for a soft match for the current node + let softMatch = findSoftMatch(newParent, oldParent, newChild, insertionPoint, ctx); - // The submission process + // if we found a soft match for the current node, morph + if (softMatch) { + insertionPoint = removeNodesBetween(insertionPoint, softMatch, ctx); + morphOldNodeTo(softMatch, newChild, ctx); + removeIdsFromConsideration(ctx, newChild); + continue; + } - async start() { - const { initialized, requesting } = FormSubmissionState; - const confirmationMessage = getAttribute("data-turbo-confirm", this.submitter, this.formElement); + // abandon all hope of morphing, just insert the new child before the insertion point + // and move on + if (ctx.callbacks.beforeNodeAdded(newChild) === false) return; - if (typeof confirmationMessage === "string") { - const confirmMethod = typeof config.forms.confirm === "function" ? - config.forms.confirm : - FormSubmission.confirmMethod; + oldParent.insertBefore(newChild, insertionPoint); + ctx.callbacks.afterNodeAdded(newChild); + removeIdsFromConsideration(ctx, newChild); + } - const answer = await confirmMethod(confirmationMessage, this.formElement, this.submitter); - if (!answer) { - return - } - } + // remove any remaining old nodes that didn't match up with new content + while (insertionPoint !== null) { - if (this.state == initialized) { - this.state = requesting; - return this.fetchRequest.perform() - } - } + let tempNode = insertionPoint; + insertionPoint = insertionPoint.nextSibling; + removeNode(tempNode, ctx); + } + } - stop() { - const { stopping, stopped } = FormSubmissionState; - if (this.state != stopping && this.state != stopped) { - this.state = stopping; - this.fetchRequest.cancel(); - return true - } - } + //============================================================================= + // Attribute Syncing Code + //============================================================================= - // Fetch request delegate + /** + * @param attr {String} the attribute to be mutated + * @param to {Element} the element that is going to be updated + * @param updateType {("update"|"remove")} + * @param ctx the merge context + * @returns {boolean} true if the attribute should be ignored, false otherwise + */ + function ignoreAttribute(attr, to, updateType, ctx) { + if(attr === 'value' && ctx.ignoreActiveValue && to === document.activeElement){ + return true; + } + return ctx.callbacks.beforeAttributeUpdated(attr, to, updateType) === false; + } - prepareRequest(request) { - if (!request.isSafe) { - const token = getCookieValue(getMetaContent("csrf-param")) || getMetaContent("csrf-token"); - if (token) { - request.headers["X-CSRF-Token"] = token; - } - } + /** + * syncs a given node with another node, copying over all attributes and + * inner element state from the 'from' node to the 'to' node + * + * @param {Element} from the element to copy attributes & state from + * @param {Element} to the element to copy attributes & state to + * @param ctx the merge context + */ + function syncNodeFrom(from, to, ctx) { + let type = from.nodeType; - if (this.requestAcceptsTurboStreamResponse(request)) { - request.acceptResponseType(StreamMessage.contentType); - } - } + // if is an element type, sync the attributes from the + // new node into the new node + if (type === 1 /* element type */) { + const fromAttributes = from.attributes; + const toAttributes = to.attributes; + for (const fromAttribute of fromAttributes) { + if (ignoreAttribute(fromAttribute.name, to, 'update', ctx)) { + continue; + } + if (to.getAttribute(fromAttribute.name) !== fromAttribute.value) { + to.setAttribute(fromAttribute.name, fromAttribute.value); + } + } + // iterate backwards to avoid skipping over items when a delete occurs + for (let i = toAttributes.length - 1; 0 <= i; i--) { + const toAttribute = toAttributes[i]; + if (ignoreAttribute(toAttribute.name, to, 'remove', ctx)) { + continue; + } + if (!from.hasAttribute(toAttribute.name)) { + to.removeAttribute(toAttribute.name); + } + } + } - 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); - } + // sync text nodes + if (type === 8 /* comment */ || type === 3 /* text */) { + if (to.nodeValue !== from.nodeValue) { + to.nodeValue = from.nodeValue; + } + } - requestPreventedHandlingResponse(request, response) { - prefetchCache.clear(); + if (!ignoreValueOfActiveElement(to, ctx)) { + // sync input values + syncInputValue(from, to, ctx); + } + } - this.result = { success: response.succeeded, fetchResponse: response }; - } + /** + * @param from {Element} element to sync the value from + * @param to {Element} element to sync the value to + * @param attributeName {String} the attribute name + * @param ctx the merge context + */ + function syncBooleanAttribute(from, to, attributeName, ctx) { + if (from[attributeName] !== to[attributeName]) { + let ignoreUpdate = ignoreAttribute(attributeName, to, 'update', ctx); + if (!ignoreUpdate) { + to[attributeName] = from[attributeName]; + } + if (from[attributeName]) { + if (!ignoreUpdate) { + to.setAttribute(attributeName, from[attributeName]); + } + } else { + if (!ignoreAttribute(attributeName, to, 'remove', ctx)) { + to.removeAttribute(attributeName); + } + } + } + } - requestSucceededWithResponse(request, response) { - if (response.clientError || response.serverError) { - this.delegate.formSubmissionFailedWithResponse(this, response); - return - } + /** + * NB: many bothans died to bring us information: + * + * https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js + * https://github.com/choojs/nanomorph/blob/master/lib/morph.jsL113 + * + * @param from {Element} the element to sync the input value from + * @param to {Element} the element to sync the input value to + * @param ctx the merge context + */ + function syncInputValue(from, to, ctx) { + if (from instanceof HTMLInputElement && + to instanceof HTMLInputElement && + from.type !== 'file') { - prefetchCache.clear(); + let fromValue = from.value; + let toValue = to.value; - 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); - } - } + // sync boolean attributes + syncBooleanAttribute(from, to, 'checked', ctx); + syncBooleanAttribute(from, to, 'disabled', ctx); - requestFailedWithResponse(request, response) { - this.result = { success: false, fetchResponse: response }; - this.delegate.formSubmissionFailedWithResponse(this, response); - } + if (!from.hasAttribute('value')) { + if (!ignoreAttribute('value', to, 'remove', ctx)) { + to.value = ''; + to.removeAttribute('value'); + } + } else if (fromValue !== toValue) { + if (!ignoreAttribute('value', to, 'update', ctx)) { + to.setAttribute('value', fromValue); + to.value = fromValue; + } + } + } else if (from instanceof HTMLOptionElement) { + syncBooleanAttribute(from, to, 'selected', ctx); + } else if (from instanceof HTMLTextAreaElement && to instanceof HTMLTextAreaElement) { + let fromValue = from.value; + let toValue = to.value; + if (ignoreAttribute('value', to, 'update', ctx)) { + return; + } + if (fromValue !== toValue) { + to.value = fromValue; + } + if (to.firstChild && to.firstChild.nodeValue !== fromValue) { + to.firstChild.nodeValue = fromValue; + } + } + } - requestErrored(request, error) { - this.result = { success: false, error }; - this.delegate.formSubmissionErrored(this, error); - } + //============================================================================= + // the HEAD tag can be handled specially, either w/ a 'merge' or 'append' style + //============================================================================= + function handleHeadElement(newHeadTag, currentHead, ctx) { - 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); - } + let added = []; + let removed = []; + let preserved = []; + let nodesToAppend = []; - // Private + let headMergeStyle = ctx.head.style; - setSubmitsWith() { - if (!this.submitter || !this.submitsWith) return + // put all new head elements into a Map, by their outerHTML + let srcToNewHeadNodes = new Map(); + for (const newHeadChild of newHeadTag.children) { + srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild); + } - 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; - } - } + // for each elt in the current head + for (const currentHeadElt of currentHead.children) { - resetSubmitterText() { - if (!this.submitter || !this.originalSubmitText) return + // If the current head element is in the map + let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML); + let isReAppended = ctx.head.shouldReAppend(currentHeadElt); + let isPreserved = ctx.head.shouldPreserve(currentHeadElt); + if (inNewContent || isPreserved) { + if (isReAppended) { + // remove the current version and let the new version replace it and re-execute + removed.push(currentHeadElt); + } else { + // this element already exists and should not be re-appended, so remove it from + // the new content map, preserving it in the DOM + srcToNewHeadNodes.delete(currentHeadElt.outerHTML); + preserved.push(currentHeadElt); + } + } else { + if (headMergeStyle === "append") { + // we are appending and this existing element is not new content + // so if and only if it is marked for re-append do we do anything + if (isReAppended) { + removed.push(currentHeadElt); + nodesToAppend.push(currentHeadElt); + } + } else { + // if this is a merge, we remove this content since it is not in the new head + if (ctx.head.shouldRemove(currentHeadElt) !== false) { + removed.push(currentHeadElt); + } + } + } + } - if (this.submitter.matches("button")) { - this.submitter.innerHTML = this.originalSubmitText; - } else if (this.submitter.matches("input")) { - const input = this.submitter; - input.value = this.originalSubmitText; - } - } + // Push the remaining new head elements in the Map into the + // nodes to append to the head tag + nodesToAppend.push(...srcToNewHeadNodes.values()); - requestMustRedirect(request) { - return !request.isSafe && this.mustRedirect - } + let promises = []; + for (const newNode of nodesToAppend) { + let newElt = document.createRange().createContextualFragment(newNode.outerHTML).firstChild; + if (ctx.callbacks.beforeNodeAdded(newElt) !== false) { + if (newElt.href || newElt.src) { + let resolve = null; + let promise = new Promise(function (_resolve) { + resolve = _resolve; + }); + newElt.addEventListener('load', function () { + resolve(); + }); + promises.push(promise); + } + currentHead.appendChild(newElt); + ctx.callbacks.afterNodeAdded(newElt); + added.push(newElt); + } + } - 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 = ""; - } + // remove all removed elements, after we have appended the new elements to avoid + // additional network requests for things like style sheets + for (const removedElement of removed) { + if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) { + currentHead.removeChild(removedElement); + ctx.callbacks.afterNodeRemoved(removedElement); + } + } - return action - } + ctx.head.afterHeadMorphed(currentHead, {added: added, kept: preserved, removed: removed}); + return promises; + } - function getMethod(formElement, submitter) { - const method = submitter?.getAttribute("formmethod") || formElement.getAttribute("method") || ""; - return fetchMethodFromString(method.toLowerCase()) || FetchMethod.get - } + function noOp() { + } - function getEnctype(formElement, submitter) { - return fetchEnctypeFromString(submitter?.getAttribute("formenctype") || formElement.enctype) - } + /* + Deep merges the config object and the Idiomoroph.defaults object to + produce a final configuration object + */ + function mergeDefaults(config) { + let finalConfig = {}; + // copy top level stuff into final config + Object.assign(finalConfig, defaults); + Object.assign(finalConfig, config); - class Snapshot { - constructor(element) { - this.element = element; - } + // copy callbacks into final config (do this to deep merge the callbacks) + finalConfig.callbacks = {}; + Object.assign(finalConfig.callbacks, defaults.callbacks); + Object.assign(finalConfig.callbacks, config.callbacks); - get activeElement() { - return this.element.ownerDocument.activeElement - } + // copy head config into final config (do this to deep merge the head) + finalConfig.head = {}; + Object.assign(finalConfig.head, defaults.head); + Object.assign(finalConfig.head, config.head); + return finalConfig; + } - get children() { - return [...this.element.children] - } + function createMorphContext(oldNode, newContent, config) { + config = mergeDefaults(config); + return { + target: oldNode, + newContent: newContent, + config: config, + morphStyle: config.morphStyle, + ignoreActive: config.ignoreActive, + ignoreActiveValue: config.ignoreActiveValue, + idMap: createIdMap(oldNode, newContent), + deadIds: new Set(), + callbacks: config.callbacks, + head: config.head + } + } - hasAnchor(anchor) { - return this.getElementForAnchor(anchor) != null - } + function isIdSetMatch(node1, node2, ctx) { + if (node1 == null || node2 == null) { + return false; + } + if (node1.nodeType === node2.nodeType && node1.tagName === node2.tagName) { + if (node1.id !== "" && node1.id === node2.id) { + return true; + } else { + return getIdIntersectionCount(ctx, node1, node2) > 0; + } + } + return false; + } - getElementForAnchor(anchor) { - return anchor ? this.element.querySelector(`[id='${anchor}'], a[name='${anchor}']`) : null - } + function isSoftMatch(node1, node2) { + if (node1 == null || node2 == null) { + return false; + } + return node1.nodeType === node2.nodeType && node1.tagName === node2.tagName + } - get isConnected() { - return this.element.isConnected - } + function removeNodesBetween(startInclusive, endExclusive, ctx) { + while (startInclusive !== endExclusive) { + let tempNode = startInclusive; + startInclusive = startInclusive.nextSibling; + removeNode(tempNode, ctx); + } + removeIdsFromConsideration(ctx, endExclusive); + return endExclusive.nextSibling; + } - get firstAutofocusableElement() { - return queryAutofocusableElement(this.element) - } + //============================================================================= + // Scans forward from the insertionPoint in the old parent looking for a potential id match + // for the newChild. We stop if we find a potential id match for the new child OR + // if the number of potential id matches we are discarding is greater than the + // potential id matches for the new child + //============================================================================= + function findIdSetMatch(newContent, oldParent, newChild, insertionPoint, ctx) { - get permanentElements() { - return queryPermanentElementsAll(this.element) - } + // max id matches we are willing to discard in our search + let newChildPotentialIdCount = getIdIntersectionCount(ctx, newChild, oldParent); - getPermanentElementById(id) { - return getPermanentElementById(this.element, id) - } + let potentialMatch = null; - getPermanentElementMapForSnapshot(snapshot) { - const permanentElementMap = {}; + // only search forward if there is a possibility of an id match + if (newChildPotentialIdCount > 0) { + let potentialMatch = insertionPoint; + // if there is a possibility of an id match, scan forward + // keep track of the potential id match count we are discarding (the + // newChildPotentialIdCount must be greater than this to make it likely + // worth it) + let otherMatchCount = 0; + while (potentialMatch != null) { - for (const currentPermanentElement of this.permanentElements) { - const { id } = currentPermanentElement; - const newPermanentElement = snapshot.getPermanentElementById(id); - if (newPermanentElement) { - permanentElementMap[id] = [currentPermanentElement, newPermanentElement]; - } - } + // If we have an id match, return the current potential match + if (isIdSetMatch(newChild, potentialMatch, ctx)) { + return potentialMatch; + } - return permanentElementMap - } - } + // computer the other potential matches of this new content + otherMatchCount += getIdIntersectionCount(ctx, potentialMatch, newContent); + if (otherMatchCount > newChildPotentialIdCount) { + // if we have more potential id matches in _other_ content, we + // do not have a good candidate for an id match, so return null + return null; + } - function getPermanentElementById(node, id) { - return node.querySelector(`#${id}[data-turbo-permanent]`) - } + // advanced to the next old content child + potentialMatch = potentialMatch.nextSibling; + } + } + return potentialMatch; + } - function queryPermanentElementsAll(node) { - return node.querySelectorAll("[id][data-turbo-permanent]") - } + //============================================================================= + // Scans forward from the insertionPoint in the old parent looking for a potential soft match + // for the newChild. We stop if we find a potential soft match for the new child OR + // if we find a potential id match in the old parents children OR if we find two + // potential soft matches for the next two pieces of new content + //============================================================================= + function findSoftMatch(newContent, oldParent, newChild, insertionPoint, ctx) { - class FormSubmitObserver { - started = false + let potentialSoftMatch = insertionPoint; + let nextSibling = newChild.nextSibling; + let siblingSoftMatchCount = 0; - constructor(delegate, eventTarget) { - this.delegate = delegate; - this.eventTarget = eventTarget; - } + while (potentialSoftMatch != null) { - start() { - if (!this.started) { - this.eventTarget.addEventListener("submit", this.submitCaptured, true); - this.started = true; - } - } + if (getIdIntersectionCount(ctx, potentialSoftMatch, newContent) > 0) { + // the current potential soft match has a potential id set match with the remaining new + // content so bail out of looking + return null; + } - stop() { - if (this.started) { - this.eventTarget.removeEventListener("submit", this.submitCaptured, true); - this.started = false; - } - } + // if we have a soft match with the current node, return it + if (isSoftMatch(newChild, potentialSoftMatch)) { + return potentialSoftMatch; + } - submitCaptured = () => { - this.eventTarget.removeEventListener("submit", this.submitBubbled, false); - this.eventTarget.addEventListener("submit", this.submitBubbled, false); - } + if (isSoftMatch(nextSibling, potentialSoftMatch)) { + // the next new node has a soft match with this node, so + // increment the count of future soft matches + siblingSoftMatchCount++; + nextSibling = nextSibling.nextSibling; - 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); - } - } - } - } + // If there are two future soft matches, bail to allow the siblings to soft match + // so that we don't consume future soft matches for the sake of the current node + if (siblingSoftMatchCount >= 2) { + return null; + } + } - function submissionDoesNotDismissDialog(form, submitter) { - const method = submitter?.getAttribute("formmethod") || form.getAttribute("method"); + // advanced to the next old content child + potentialSoftMatch = potentialSoftMatch.nextSibling; + } - return method != "dialog" - } + return potentialSoftMatch; + } - function submissionDoesNotTargetIFrame(form, submitter) { - const target = submitter?.getAttribute("formtarget") || form.getAttribute("target"); + function parseContent(newContent) { + let parser = new DOMParser(); - return doesNotTargetIFrame(target) - } + // remove svgs to avoid false-positive matches on head, etc. + let contentWithSvgsRemoved = newContent.replace(/]*>|>)([\s\S]*?)<\/svg>/gim, ''); - class View { - #resolveRenderPromise = (_value) => {} - #resolveInterceptionPromise = (_value) => {} + // if the newContent contains a html, head or body tag, we can simply parse it w/o wrapping + if (contentWithSvgsRemoved.match(/<\/html>/) || contentWithSvgsRemoved.match(/<\/head>/) || contentWithSvgsRemoved.match(/<\/body>/)) { + let content = parser.parseFromString(newContent, "text/html"); + // if it is a full HTML document, return the document itself as the parent container + if (contentWithSvgsRemoved.match(/<\/html>/)) { + content.generatedByIdiomorph = true; + return content; + } else { + // otherwise return the html element as the parent container + let htmlElement = content.firstChild; + if (htmlElement) { + htmlElement.generatedByIdiomorph = true; + return htmlElement; + } else { + return null; + } + } + } else { + // if it is partial HTML, wrap it in a template tag to provide a parent element and also to help + // deal with touchy tags like tr, tbody, etc. + let responseDoc = parser.parseFromString("", "text/html"); + let content = responseDoc.body.querySelector('template').content; + content.generatedByIdiomorph = true; + return content + } + } - constructor(delegate, element) { - this.delegate = delegate; - this.element = element; - } + function normalizeContent(newContent) { + if (newContent == null) { + // noinspection UnnecessaryLocalVariableJS + const dummyParent = document.createElement('div'); + return dummyParent; + } else if (newContent.generatedByIdiomorph) { + // the template tag created by idiomorph parsing can serve as a dummy parent + return newContent; + } else if (newContent instanceof Node) { + // a single node is added as a child to a dummy parent + const dummyParent = document.createElement('div'); + dummyParent.append(newContent); + return dummyParent; + } else { + // all nodes in the array or HTMLElement collection are consolidated under + // a single dummy parent element + const dummyParent = document.createElement('div'); + for (const elt of [...newContent]) { + dummyParent.append(elt); + } + return dummyParent; + } + } - // Scrolling - - scrollToAnchor(anchor) { - const element = this.snapshot.getElementForAnchor(anchor); - if (element) { - this.scrollToElement(element); - this.focusElement(element); - } else { - this.scrollToPosition({ x: 0, y: 0 }); - } - } + function insertSiblings(previousSibling, morphedNode, nextSibling) { + let stack = []; + let added = []; + while (previousSibling != null) { + stack.push(previousSibling); + previousSibling = previousSibling.previousSibling; + } + while (stack.length > 0) { + let node = stack.pop(); + added.push(node); // push added preceding siblings on in order and insert + morphedNode.parentElement.insertBefore(node, morphedNode); + } + added.push(morphedNode); + while (nextSibling != null) { + stack.push(nextSibling); + added.push(nextSibling); // here we are going in order, so push on as we scan, rather than add + nextSibling = nextSibling.nextSibling; + } + while (stack.length > 0) { + morphedNode.parentElement.insertBefore(stack.pop(), morphedNode.nextSibling); + } + return added; + } - scrollToAnchorFromLocation(location) { - this.scrollToAnchor(getAnchor(location)); - } + function findBestNodeMatch(newContent, oldNode, ctx) { + let currentElement; + currentElement = newContent.firstChild; + let bestElement = currentElement; + let score = 0; + while (currentElement) { + let newScore = scoreElement(currentElement, oldNode, ctx); + if (newScore > score) { + bestElement = currentElement; + score = newScore; + } + currentElement = currentElement.nextSibling; + } + return bestElement; + } - scrollToElement(element) { - element.scrollIntoView(); - } + function scoreElement(node1, node2, ctx) { + if (isSoftMatch(node1, node2)) { + return .5 + getIdIntersectionCount(ctx, node1, node2); + } + return 0; + } - focusElement(element) { - if (element instanceof HTMLElement) { - if (element.hasAttribute("tabindex")) { - element.focus(); - } else { - element.setAttribute("tabindex", "-1"); - element.focus(); - element.removeAttribute("tabindex"); - } - } - } + function removeNode(tempNode, ctx) { + removeIdsFromConsideration(ctx, tempNode); + if (ctx.callbacks.beforeNodeRemoved(tempNode) === false) return; - scrollToPosition({ x, y }) { - this.scrollRoot.scrollTo(x, y); - } + tempNode.remove(); + ctx.callbacks.afterNodeRemoved(tempNode); + } - scrollToTop() { - this.scrollToPosition({ x: 0, y: 0 }); - } + //============================================================================= + // ID Set Functions + //============================================================================= - get scrollRoot() { - return window - } + function isIdInConsideration(ctx, id) { + return !ctx.deadIds.has(id); + } - // Rendering + function idIsWithinNode(ctx, id, targetNode) { + let idSet = ctx.idMap.get(targetNode) || EMPTY_SET; + return idSet.has(id); + } - async render(renderer) { - const { isPreview, shouldRender, willRender, newSnapshot: snapshot } = renderer; + function removeIdsFromConsideration(ctx, node) { + let idSet = ctx.idMap.get(node) || EMPTY_SET; + for (const id of idSet) { + ctx.deadIds.add(id); + } + } - // A workaround to ignore tracked element mismatch reloads when performing - // a promoted Visit from a frame navigation - const shouldInvalidate = willRender; + function getIdIntersectionCount(ctx, node1, node2) { + let sourceSet = ctx.idMap.get(node1) || EMPTY_SET; + let matchCount = 0; + for (const id of sourceSet) { + // a potential match is an id in the source and potentialIdsSet, but + // that has not already been merged into the DOM + if (isIdInConsideration(ctx, id) && idIsWithinNode(ctx, id, node2)) { + ++matchCount; + } + } + return matchCount; + } - 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); - } - } + /** + * A bottom up algorithm that finds all elements with ids inside of the node + * argument and populates id sets for those nodes and all their parents, generating + * a set of ids contained within all nodes for the entire hierarchy in the DOM + * + * @param node {Element} + * @param {Map>} idMap + */ + function populateIdMapForNode(node, idMap) { + let nodeParent = node.parentElement; + // find all elements with an id property + let idElements = node.querySelectorAll('[id]'); + for (const elt of idElements) { + let current = elt; + // walk up the parent hierarchy of that element, adding the id + // of element to the parent's id set + while (current !== nodeParent && current != null) { + let idSet = idMap.get(current); + // if the id set doesn't exist, create it and insert it in the map + if (idSet == null) { + idSet = new Set(); + idMap.set(current, idSet); + } + idSet.add(elt.id); + current = current.parentElement; + } + } + } - invalidate(reason) { - this.delegate.viewInvalidated(reason); - } + /** + * This function computes a map of nodes to all ids contained within that node (inclusive of the + * node). This map can be used to ask if two nodes have intersecting sets of ids, which allows + * for a looser definition of "matching" than tradition id matching, and allows child nodes + * to contribute to a parent nodes matching. + * + * @param {Element} oldContent the old content that will be morphed + * @param {Element} newContent the new content to morph to + * @returns {Map>} a map of nodes to id sets for the + */ + function createIdMap(oldContent, newContent) { + let idMap = new Map(); + populateIdMapForNode(oldContent, idMap); + populateIdMapForNode(newContent, idMap); + return idMap; + } - async prepareToRenderSnapshot(renderer) { - this.markAsPreview(renderer.isPreview); - await renderer.prepareToRender(); - } + //============================================================================= + // This is what ends up becoming the Idiomorph global object + //============================================================================= + return { + morph, + defaults + } + })(); - markAsPreview(isPreview) { - if (isPreview) { - this.element.setAttribute("data-turbo-preview", ""); - } else { - this.element.removeAttribute("data-turbo-preview"); + function log() { + if (HotwireSpark.config.loggingEnabled) { + for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) { + args[_key] = arguments[_key]; } - } - - markVisitDirection(direction) { - this.element.setAttribute("data-turbo-visit-direction", direction); - } - - unmarkVisitDirection() { - this.element.removeAttribute("data-turbo-visit-direction"); - } - - async renderSnapshot(renderer) { - await renderer.render(); - } - - finishRenderingSnapshot(renderer) { - renderer.finishRendering(); - } - } - - class FrameView extends View { - missing() { - this.element.innerHTML = `Content missing`; - } - - get snapshot() { - return new Snapshot(this.element) + console.log(`[hotwire_spark]`, ...args); } } - 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; + /* + Stimulus 3.2.1 + Copyright © 2023 Basecamp, LLC + */ + class EventListener { + constructor(eventTarget, eventName, eventOptions) { + this.eventTarget = eventTarget; + this.eventName = eventName; + this.eventOptions = eventOptions; + this.unorderedBindings = new Set(); } - } - - 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); - } + connect() { + this.eventTarget.addEventListener(this.eventName, this, this.eventOptions); } - 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; + disconnect() { + this.eventTarget.removeEventListener(this.eventName, this, this.eventOptions); } - } - - stop() { - if (this.started) { - this.eventTarget.removeEventListener("click", this.clickCaptured, true); - this.started = false; + bindingConnected(binding) { + this.unorderedBindings.add(binding); } - } - - 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); + bindingDisconnected(binding) { + this.unorderedBindings.delete(binding); + } + handleEvent(event) { + const extendedEvent = extendEvent(event); + for (const binding of this.bindings) { + if (extendedEvent.immediatePropagationStopped) { + break; + } + else { + binding.handleEvent(extendedEvent); + } } - } - } - } - - 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 - } - } - - // base IIFE to define idiomorph - var Idiomorph$1 = (function () { - - //============================================================================= - // AND NOW IT BEGINS... - //============================================================================= - let EMPTY_SET = new Set(); - - // default configuration values, updatable by users now - let defaults = { - morphStyle: "outerHTML", - callbacks : { - beforeNodeAdded: noOp, - afterNodeAdded: noOp, - beforeNodeMorphed: noOp, - afterNodeMorphed: noOp, - beforeNodeRemoved: noOp, - afterNodeRemoved: noOp, - beforeAttributeUpdated: noOp, - - }, - head: { - style: 'merge', - shouldPreserve: function (elt) { - return elt.getAttribute("im-preserve") === "true"; - }, - shouldReAppend: function (elt) { - return elt.getAttribute("im-re-append") === "true"; - }, - shouldRemove: noOp, - afterHeadMorphed: noOp, - } - }; - - //============================================================================= - // Core Morphing Algorithm - morph, morphNormalizedContent, morphOldNodeTo, morphChildren - //============================================================================= - function morph(oldNode, newContent, config = {}) { - - if (oldNode instanceof Document) { - oldNode = oldNode.documentElement; - } - - if (typeof newContent === 'string') { - newContent = parseContent(newContent); - } - - let normalizedContent = normalizeContent(newContent); - - let ctx = createMorphContext(oldNode, normalizedContent, config); - - return morphNormalizedContent(oldNode, normalizedContent, ctx); - } - - function morphNormalizedContent(oldNode, normalizedNewContent, ctx) { - if (ctx.head.block) { - let oldHead = oldNode.querySelector('head'); - let newHead = normalizedNewContent.querySelector('head'); - if (oldHead && newHead) { - let promises = handleHeadElement(newHead, oldHead, ctx); - // when head promises resolve, call morph again, ignoring the head tag - Promise.all(promises).then(function () { - morphNormalizedContent(oldNode, normalizedNewContent, Object.assign(ctx, { - head: { - block: false, - ignore: true - } - })); - }); - return; - } - } - - if (ctx.morphStyle === "innerHTML") { - - // innerHTML, so we are only updating the children - morphChildren(normalizedNewContent, oldNode, ctx); - return oldNode.children; - - } else if (ctx.morphStyle === "outerHTML" || ctx.morphStyle == null) { - // otherwise find the best element match in the new content, morph that, and merge its siblings - // into either side of the best match - let bestMatch = findBestNodeMatch(normalizedNewContent, oldNode, ctx); - - // stash the siblings that will need to be inserted on either side of the best match - let previousSibling = bestMatch?.previousSibling; - let nextSibling = bestMatch?.nextSibling; - - // morph it - let morphedNode = morphOldNodeTo(oldNode, bestMatch, ctx); - - if (bestMatch) { - // if there was a best match, merge the siblings in too and return the - // whole bunch - return insertSiblings(previousSibling, morphedNode, nextSibling); - } else { - // otherwise nothing was added to the DOM - return [] - } - } else { - throw "Do not understand how to morph style " + ctx.morphStyle; - } - } - - - /** - * @param possibleActiveElement - * @param ctx - * @returns {boolean} - */ - function ignoreValueOfActiveElement(possibleActiveElement, ctx) { - return ctx.ignoreActiveValue && possibleActiveElement === document.activeElement && possibleActiveElement !== document.body; - } - - /** - * @param oldNode root node to merge content into - * @param newContent new content to merge - * @param ctx the merge context - * @returns {Element} the element that ended up in the DOM - */ - function morphOldNodeTo(oldNode, newContent, ctx) { - if (ctx.ignoreActive && oldNode === document.activeElement) ; else if (newContent == null) { - if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode; - - oldNode.remove(); - ctx.callbacks.afterNodeRemoved(oldNode); - return null; - } else if (!isSoftMatch(oldNode, newContent)) { - if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode; - if (ctx.callbacks.beforeNodeAdded(newContent) === false) return oldNode; - - oldNode.parentElement.replaceChild(newContent, oldNode); - ctx.callbacks.afterNodeAdded(newContent); - ctx.callbacks.afterNodeRemoved(oldNode); - return newContent; - } else { - if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) return oldNode; - - if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) ; else if (oldNode instanceof HTMLHeadElement && ctx.head.style !== "morph") { - handleHeadElement(newContent, oldNode, ctx); - } else { - syncNodeFrom(newContent, oldNode, ctx); - if (!ignoreValueOfActiveElement(oldNode, ctx)) { - morphChildren(newContent, oldNode, ctx); - } - } - ctx.callbacks.afterNodeMorphed(oldNode, newContent); - return oldNode; - } - } - - /** - * This is the core algorithm for matching up children. The idea is to use id sets to try to match up - * nodes as faithfully as possible. We greedily match, which allows us to keep the algorithm fast, but - * by using id sets, we are able to better match up with content deeper in the DOM. - * - * Basic algorithm is, for each node in the new content: - * - * - if we have reached the end of the old parent, append the new content - * - if the new content has an id set match with the current insertion point, morph - * - search for an id set match - * - if id set match found, morph - * - otherwise search for a "soft" match - * - if a soft match is found, morph - * - otherwise, prepend the new node before the current insertion point - * - * The two search algorithms terminate if competing node matches appear to outweigh what can be achieved - * with the current node. See findIdSetMatch() and findSoftMatch() for details. - * - * @param {Element} newParent the parent element of the new content - * @param {Element } oldParent the old content that we are merging the new content into - * @param ctx the merge context - */ - function morphChildren(newParent, oldParent, ctx) { - - let nextNewChild = newParent.firstChild; - let insertionPoint = oldParent.firstChild; - let newChild; - - // run through all the new content - while (nextNewChild) { - - newChild = nextNewChild; - nextNewChild = newChild.nextSibling; - - // if we are at the end of the exiting parent's children, just append - if (insertionPoint == null) { - if (ctx.callbacks.beforeNodeAdded(newChild) === false) return; - - oldParent.appendChild(newChild); - ctx.callbacks.afterNodeAdded(newChild); - removeIdsFromConsideration(ctx, newChild); - continue; - } - - // if the current node has an id set match then morph - if (isIdSetMatch(newChild, insertionPoint, ctx)) { - morphOldNodeTo(insertionPoint, newChild, ctx); - insertionPoint = insertionPoint.nextSibling; - removeIdsFromConsideration(ctx, newChild); - continue; - } - - // otherwise search forward in the existing old children for an id set match - let idSetMatch = findIdSetMatch(newParent, oldParent, newChild, insertionPoint, ctx); - - // if we found a potential match, remove the nodes until that point and morph - if (idSetMatch) { - insertionPoint = removeNodesBetween(insertionPoint, idSetMatch, ctx); - morphOldNodeTo(idSetMatch, newChild, ctx); - removeIdsFromConsideration(ctx, newChild); - continue; - } - - // no id set match found, so scan forward for a soft match for the current node - let softMatch = findSoftMatch(newParent, oldParent, newChild, insertionPoint, ctx); - - // if we found a soft match for the current node, morph - if (softMatch) { - insertionPoint = removeNodesBetween(insertionPoint, softMatch, ctx); - morphOldNodeTo(softMatch, newChild, ctx); - removeIdsFromConsideration(ctx, newChild); - continue; - } - - // abandon all hope of morphing, just insert the new child before the insertion point - // and move on - if (ctx.callbacks.beforeNodeAdded(newChild) === false) return; - - oldParent.insertBefore(newChild, insertionPoint); - ctx.callbacks.afterNodeAdded(newChild); - removeIdsFromConsideration(ctx, newChild); - } - - // remove any remaining old nodes that didn't match up with new content - while (insertionPoint !== null) { - - let tempNode = insertionPoint; - insertionPoint = insertionPoint.nextSibling; - removeNode(tempNode, ctx); - } - } - - //============================================================================= - // Attribute Syncing Code - //============================================================================= - - /** - * @param attr {String} the attribute to be mutated - * @param to {Element} the element that is going to be updated - * @param updateType {("update"|"remove")} - * @param ctx the merge context - * @returns {boolean} true if the attribute should be ignored, false otherwise - */ - function ignoreAttribute(attr, to, updateType, ctx) { - if(attr === 'value' && ctx.ignoreActiveValue && to === document.activeElement){ - return true; - } - return ctx.callbacks.beforeAttributeUpdated(attr, to, updateType) === false; - } - - /** - * syncs a given node with another node, copying over all attributes and - * inner element state from the 'from' node to the 'to' node - * - * @param {Element} from the element to copy attributes & state from - * @param {Element} to the element to copy attributes & state to - * @param ctx the merge context - */ - function syncNodeFrom(from, to, ctx) { - let type = from.nodeType; - - // if is an element type, sync the attributes from the - // new node into the new node - if (type === 1 /* element type */) { - const fromAttributes = from.attributes; - const toAttributes = to.attributes; - for (const fromAttribute of fromAttributes) { - if (ignoreAttribute(fromAttribute.name, to, 'update', ctx)) { - continue; - } - if (to.getAttribute(fromAttribute.name) !== fromAttribute.value) { - to.setAttribute(fromAttribute.name, fromAttribute.value); - } - } - // iterate backwards to avoid skipping over items when a delete occurs - for (let i = toAttributes.length - 1; 0 <= i; i--) { - const toAttribute = toAttributes[i]; - if (ignoreAttribute(toAttribute.name, to, 'remove', ctx)) { - continue; - } - if (!from.hasAttribute(toAttribute.name)) { - to.removeAttribute(toAttribute.name); - } - } - } - - // sync text nodes - if (type === 8 /* comment */ || type === 3 /* text */) { - if (to.nodeValue !== from.nodeValue) { - to.nodeValue = from.nodeValue; - } - } - - if (!ignoreValueOfActiveElement(to, ctx)) { - // sync input values - syncInputValue(from, to, ctx); - } - } - - /** - * @param from {Element} element to sync the value from - * @param to {Element} element to sync the value to - * @param attributeName {String} the attribute name - * @param ctx the merge context - */ - function syncBooleanAttribute(from, to, attributeName, ctx) { - if (from[attributeName] !== to[attributeName]) { - let ignoreUpdate = ignoreAttribute(attributeName, to, 'update', ctx); - if (!ignoreUpdate) { - to[attributeName] = from[attributeName]; - } - if (from[attributeName]) { - if (!ignoreUpdate) { - to.setAttribute(attributeName, from[attributeName]); - } - } else { - if (!ignoreAttribute(attributeName, to, 'remove', ctx)) { - to.removeAttribute(attributeName); - } - } - } - } - - /** - * NB: many bothans died to bring us information: - * - * https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js - * https://github.com/choojs/nanomorph/blob/master/lib/morph.jsL113 - * - * @param from {Element} the element to sync the input value from - * @param to {Element} the element to sync the input value to - * @param ctx the merge context - */ - function syncInputValue(from, to, ctx) { - if (from instanceof HTMLInputElement && - to instanceof HTMLInputElement && - from.type !== 'file') { - - let fromValue = from.value; - let toValue = to.value; - - // sync boolean attributes - syncBooleanAttribute(from, to, 'checked', ctx); - syncBooleanAttribute(from, to, 'disabled', ctx); - - if (!from.hasAttribute('value')) { - if (!ignoreAttribute('value', to, 'remove', ctx)) { - to.value = ''; - to.removeAttribute('value'); - } - } else if (fromValue !== toValue) { - if (!ignoreAttribute('value', to, 'update', ctx)) { - to.setAttribute('value', fromValue); - to.value = fromValue; - } - } - } else if (from instanceof HTMLOptionElement) { - syncBooleanAttribute(from, to, 'selected', ctx); - } else if (from instanceof HTMLTextAreaElement && to instanceof HTMLTextAreaElement) { - let fromValue = from.value; - let toValue = to.value; - if (ignoreAttribute('value', to, 'update', ctx)) { - return; - } - if (fromValue !== toValue) { - to.value = fromValue; - } - if (to.firstChild && to.firstChild.nodeValue !== fromValue) { - to.firstChild.nodeValue = fromValue; - } - } - } - - //============================================================================= - // the HEAD tag can be handled specially, either w/ a 'merge' or 'append' style - //============================================================================= - function handleHeadElement(newHeadTag, currentHead, ctx) { - - let added = []; - let removed = []; - let preserved = []; - let nodesToAppend = []; - - let headMergeStyle = ctx.head.style; - - // put all new head elements into a Map, by their outerHTML - let srcToNewHeadNodes = new Map(); - for (const newHeadChild of newHeadTag.children) { - srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild); - } - - // for each elt in the current head - for (const currentHeadElt of currentHead.children) { - - // If the current head element is in the map - let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML); - let isReAppended = ctx.head.shouldReAppend(currentHeadElt); - let isPreserved = ctx.head.shouldPreserve(currentHeadElt); - if (inNewContent || isPreserved) { - if (isReAppended) { - // remove the current version and let the new version replace it and re-execute - removed.push(currentHeadElt); - } else { - // this element already exists and should not be re-appended, so remove it from - // the new content map, preserving it in the DOM - srcToNewHeadNodes.delete(currentHeadElt.outerHTML); - preserved.push(currentHeadElt); - } - } else { - if (headMergeStyle === "append") { - // we are appending and this existing element is not new content - // so if and only if it is marked for re-append do we do anything - if (isReAppended) { - removed.push(currentHeadElt); - nodesToAppend.push(currentHeadElt); - } - } else { - // if this is a merge, we remove this content since it is not in the new head - if (ctx.head.shouldRemove(currentHeadElt) !== false) { - removed.push(currentHeadElt); - } - } - } - } - - // Push the remaining new head elements in the Map into the - // nodes to append to the head tag - nodesToAppend.push(...srcToNewHeadNodes.values()); - - let promises = []; - for (const newNode of nodesToAppend) { - let newElt = document.createRange().createContextualFragment(newNode.outerHTML).firstChild; - if (ctx.callbacks.beforeNodeAdded(newElt) !== false) { - if (newElt.href || newElt.src) { - let resolve = null; - let promise = new Promise(function (_resolve) { - resolve = _resolve; - }); - newElt.addEventListener('load', function () { - resolve(); - }); - promises.push(promise); - } - currentHead.appendChild(newElt); - ctx.callbacks.afterNodeAdded(newElt); - added.push(newElt); - } - } - - // remove all removed elements, after we have appended the new elements to avoid - // additional network requests for things like style sheets - for (const removedElement of removed) { - if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) { - currentHead.removeChild(removedElement); - ctx.callbacks.afterNodeRemoved(removedElement); - } - } - - ctx.head.afterHeadMorphed(currentHead, {added: added, kept: preserved, removed: removed}); - return promises; - } - - function noOp() { - } - - /* - Deep merges the config object and the Idiomoroph.defaults object to - produce a final configuration object - */ - function mergeDefaults(config) { - let finalConfig = {}; - // copy top level stuff into final config - Object.assign(finalConfig, defaults); - Object.assign(finalConfig, config); - - // copy callbacks into final config (do this to deep merge the callbacks) - finalConfig.callbacks = {}; - Object.assign(finalConfig.callbacks, defaults.callbacks); - Object.assign(finalConfig.callbacks, config.callbacks); - - // copy head config into final config (do this to deep merge the head) - finalConfig.head = {}; - Object.assign(finalConfig.head, defaults.head); - Object.assign(finalConfig.head, config.head); - return finalConfig; - } - - function createMorphContext(oldNode, newContent, config) { - config = mergeDefaults(config); - return { - target: oldNode, - newContent: newContent, - config: config, - morphStyle: config.morphStyle, - ignoreActive: config.ignoreActive, - ignoreActiveValue: config.ignoreActiveValue, - idMap: createIdMap(oldNode, newContent), - deadIds: new Set(), - callbacks: config.callbacks, - head: config.head - } - } - - function isIdSetMatch(node1, node2, ctx) { - if (node1 == null || node2 == null) { - return false; - } - if (node1.nodeType === node2.nodeType && node1.tagName === node2.tagName) { - if (node1.id !== "" && node1.id === node2.id) { - return true; - } else { - return getIdIntersectionCount(ctx, node1, node2) > 0; - } - } - return false; - } - - function isSoftMatch(node1, node2) { - if (node1 == null || node2 == null) { - return false; - } - return node1.nodeType === node2.nodeType && node1.tagName === node2.tagName - } - - function removeNodesBetween(startInclusive, endExclusive, ctx) { - while (startInclusive !== endExclusive) { - let tempNode = startInclusive; - startInclusive = startInclusive.nextSibling; - removeNode(tempNode, ctx); - } - removeIdsFromConsideration(ctx, endExclusive); - return endExclusive.nextSibling; - } - - //============================================================================= - // Scans forward from the insertionPoint in the old parent looking for a potential id match - // for the newChild. We stop if we find a potential id match for the new child OR - // if the number of potential id matches we are discarding is greater than the - // potential id matches for the new child - //============================================================================= - function findIdSetMatch(newContent, oldParent, newChild, insertionPoint, ctx) { - - // max id matches we are willing to discard in our search - let newChildPotentialIdCount = getIdIntersectionCount(ctx, newChild, oldParent); - - let potentialMatch = null; - - // only search forward if there is a possibility of an id match - if (newChildPotentialIdCount > 0) { - let potentialMatch = insertionPoint; - // if there is a possibility of an id match, scan forward - // keep track of the potential id match count we are discarding (the - // newChildPotentialIdCount must be greater than this to make it likely - // worth it) - let otherMatchCount = 0; - while (potentialMatch != null) { - - // If we have an id match, return the current potential match - if (isIdSetMatch(newChild, potentialMatch, ctx)) { - return potentialMatch; - } - - // computer the other potential matches of this new content - otherMatchCount += getIdIntersectionCount(ctx, potentialMatch, newContent); - if (otherMatchCount > newChildPotentialIdCount) { - // if we have more potential id matches in _other_ content, we - // do not have a good candidate for an id match, so return null - return null; - } - - // advanced to the next old content child - potentialMatch = potentialMatch.nextSibling; - } - } - return potentialMatch; - } - - //============================================================================= - // Scans forward from the insertionPoint in the old parent looking for a potential soft match - // for the newChild. We stop if we find a potential soft match for the new child OR - // if we find a potential id match in the old parents children OR if we find two - // potential soft matches for the next two pieces of new content - //============================================================================= - function findSoftMatch(newContent, oldParent, newChild, insertionPoint, ctx) { - - let potentialSoftMatch = insertionPoint; - let nextSibling = newChild.nextSibling; - let siblingSoftMatchCount = 0; - - while (potentialSoftMatch != null) { - - if (getIdIntersectionCount(ctx, potentialSoftMatch, newContent) > 0) { - // the current potential soft match has a potential id set match with the remaining new - // content so bail out of looking - return null; - } - - // if we have a soft match with the current node, return it - if (isSoftMatch(newChild, potentialSoftMatch)) { - return potentialSoftMatch; - } - - if (isSoftMatch(nextSibling, potentialSoftMatch)) { - // the next new node has a soft match with this node, so - // increment the count of future soft matches - siblingSoftMatchCount++; - nextSibling = nextSibling.nextSibling; - - // If there are two future soft matches, bail to allow the siblings to soft match - // so that we don't consume future soft matches for the sake of the current node - if (siblingSoftMatchCount >= 2) { - return null; - } - } - - // advanced to the next old content child - potentialSoftMatch = potentialSoftMatch.nextSibling; - } - - return potentialSoftMatch; - } - - function parseContent(newContent) { - let parser = new DOMParser(); - - // remove svgs to avoid false-positive matches on head, etc. - let contentWithSvgsRemoved = newContent.replace(/]*>|>)([\s\S]*?)<\/svg>/gim, ''); - - // if the newContent contains a html, head or body tag, we can simply parse it w/o wrapping - if (contentWithSvgsRemoved.match(/<\/html>/) || contentWithSvgsRemoved.match(/<\/head>/) || contentWithSvgsRemoved.match(/<\/body>/)) { - let content = parser.parseFromString(newContent, "text/html"); - // if it is a full HTML document, return the document itself as the parent container - if (contentWithSvgsRemoved.match(/<\/html>/)) { - content.generatedByIdiomorph = true; - return content; - } else { - // otherwise return the html element as the parent container - let htmlElement = content.firstChild; - if (htmlElement) { - htmlElement.generatedByIdiomorph = true; - return htmlElement; - } else { - return null; - } - } - } else { - // if it is partial HTML, wrap it in a template tag to provide a parent element and also to help - // deal with touchy tags like tr, tbody, etc. - let responseDoc = parser.parseFromString("", "text/html"); - let content = responseDoc.body.querySelector('template').content; - content.generatedByIdiomorph = true; - return content - } - } - - function normalizeContent(newContent) { - if (newContent == null) { - // noinspection UnnecessaryLocalVariableJS - const dummyParent = document.createElement('div'); - return dummyParent; - } else if (newContent.generatedByIdiomorph) { - // the template tag created by idiomorph parsing can serve as a dummy parent - return newContent; - } else if (newContent instanceof Node) { - // a single node is added as a child to a dummy parent - const dummyParent = document.createElement('div'); - dummyParent.append(newContent); - return dummyParent; - } else { - // all nodes in the array or HTMLElement collection are consolidated under - // a single dummy parent element - const dummyParent = document.createElement('div'); - for (const elt of [...newContent]) { - dummyParent.append(elt); - } - return dummyParent; - } - } - - function insertSiblings(previousSibling, morphedNode, nextSibling) { - let stack = []; - let added = []; - while (previousSibling != null) { - stack.push(previousSibling); - previousSibling = previousSibling.previousSibling; - } - while (stack.length > 0) { - let node = stack.pop(); - added.push(node); // push added preceding siblings on in order and insert - morphedNode.parentElement.insertBefore(node, morphedNode); - } - added.push(morphedNode); - while (nextSibling != null) { - stack.push(nextSibling); - added.push(nextSibling); // here we are going in order, so push on as we scan, rather than add - nextSibling = nextSibling.nextSibling; - } - while (stack.length > 0) { - morphedNode.parentElement.insertBefore(stack.pop(), morphedNode.nextSibling); - } - return added; - } - - function findBestNodeMatch(newContent, oldNode, ctx) { - let currentElement; - currentElement = newContent.firstChild; - let bestElement = currentElement; - let score = 0; - while (currentElement) { - let newScore = scoreElement(currentElement, oldNode, ctx); - if (newScore > score) { - bestElement = currentElement; - score = newScore; - } - currentElement = currentElement.nextSibling; - } - return bestElement; - } - - function scoreElement(node1, node2, ctx) { - if (isSoftMatch(node1, node2)) { - return .5 + getIdIntersectionCount(ctx, node1, node2); - } - return 0; - } - - function removeNode(tempNode, ctx) { - removeIdsFromConsideration(ctx, tempNode); - if (ctx.callbacks.beforeNodeRemoved(tempNode) === false) return; - - tempNode.remove(); - ctx.callbacks.afterNodeRemoved(tempNode); - } - - //============================================================================= - // ID Set Functions - //============================================================================= - - function isIdInConsideration(ctx, id) { - return !ctx.deadIds.has(id); - } - - function idIsWithinNode(ctx, id, targetNode) { - let idSet = ctx.idMap.get(targetNode) || EMPTY_SET; - return idSet.has(id); - } - - function removeIdsFromConsideration(ctx, node) { - let idSet = ctx.idMap.get(node) || EMPTY_SET; - for (const id of idSet) { - ctx.deadIds.add(id); - } - } - - function getIdIntersectionCount(ctx, node1, node2) { - let sourceSet = ctx.idMap.get(node1) || EMPTY_SET; - let matchCount = 0; - for (const id of sourceSet) { - // a potential match is an id in the source and potentialIdsSet, but - // that has not already been merged into the DOM - if (isIdInConsideration(ctx, id) && idIsWithinNode(ctx, id, node2)) { - ++matchCount; - } - } - return matchCount; - } - - /** - * A bottom up algorithm that finds all elements with ids inside of the node - * argument and populates id sets for those nodes and all their parents, generating - * a set of ids contained within all nodes for the entire hierarchy in the DOM - * - * @param node {Element} - * @param {Map>} idMap - */ - function populateIdMapForNode(node, idMap) { - let nodeParent = node.parentElement; - // find all elements with an id property - let idElements = node.querySelectorAll('[id]'); - for (const elt of idElements) { - let current = elt; - // walk up the parent hierarchy of that element, adding the id - // of element to the parent's id set - while (current !== nodeParent && current != null) { - let idSet = idMap.get(current); - // if the id set doesn't exist, create it and insert it in the map - if (idSet == null) { - idSet = new Set(); - idMap.set(current, idSet); - } - idSet.add(elt.id); - current = current.parentElement; - } - } - } - - /** - * This function computes a map of nodes to all ids contained within that node (inclusive of the - * node). This map can be used to ask if two nodes have intersecting sets of ids, which allows - * for a looser definition of "matching" than tradition id matching, and allows child nodes - * to contribute to a parent nodes matching. - * - * @param {Element} oldContent the old content that will be morphed - * @param {Element} newContent the new content to morph to - * @returns {Map>} a map of nodes to id sets for the - */ - function createIdMap(oldContent, newContent) { - let idMap = new Map(); - populateIdMapForNode(oldContent, idMap); - populateIdMapForNode(newContent, idMap); - return idMap; - } - - //============================================================================= - // This is what ends up becoming the Idiomorph global object - //============================================================================= - return { - morph, - defaults - } - })(); - - function morphElements(currentElement, newElement, { callbacks, ...options } = {}) { - Idiomorph$1.morph(currentElement, newElement, { - ...options, - callbacks: new DefaultIdiomorphCallbacks(callbacks) - }); - } - - function morphChildren(currentElement, newElement) { - morphElements(currentElement, newElement.children, { - morphStyle: "innerHTML" - }); - } - - class DefaultIdiomorphCallbacks { - #beforeNodeMorphed - - constructor({ beforeNodeMorphed } = {}) { - this.#beforeNodeMorphed = beforeNodeMorphed || (() => true); - } - - beforeNodeAdded = (node) => { - return !(node.id && node.hasAttribute("data-turbo-permanent") && document.getElementById(node.id)) - } - - beforeNodeMorphed = (currentElement, newElement) => { - if (currentElement instanceof Element) { - if (!currentElement.hasAttribute("data-turbo-permanent") && this.#beforeNodeMorphed(currentElement, newElement)) { - const event = dispatch("turbo:before-morph-element", { - cancelable: true, - target: currentElement, - detail: { currentElement, newElement } - }); - - return !event.defaultPrevented - } else { - return false - } - } - } - - beforeAttributeUpdated = (attributeName, target, mutationType) => { - const event = dispatch("turbo:before-morph-attribute", { - cancelable: true, - target, - detail: { attributeName, mutationType } - }); - - return !event.defaultPrevented - } - - beforeNodeRemoved = (node) => { - return this.beforeNodeMorphed(node) - } - - afterNodeMorphed = (currentElement, newElement) => { - if (currentElement instanceof Element) { - dispatch("turbo:morph-element", { - target: currentElement, - detail: { currentElement, newElement } - }); - } - } - } - - class MorphingFrameRenderer extends FrameRenderer { - static renderElement(currentElement, newElement) { - dispatch("turbo:before-frame-morph", { - target: currentElement, - detail: { currentElement, newElement } - }); - - morphChildren(currentElement, newElement); - } - - 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 - - getHistoryMethodForAction(action) { - switch (action) { - case "replace": - return history.replaceState - case "advance": - case "restore": - return history.pushState - } - } - - hasPreloadedResponse() { - return typeof this.response == "object" - } - - shouldIssueRequest() { - if (this.isSamePage) { - return false - } else if (this.action == "restore") { - return !this.hasCachedSnapshot() - } else { - return this.willRender - } - } - - cacheSnapshot() { - if (!this.snapshotCached) { - this.view.cacheSnapshot(this.snapshot).then((snapshot) => snapshot && this.visitCachedSnapshot(snapshot)); - this.snapshotCached = true; - } - } - - async render(callback) { - this.cancelRender(); - 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) {} - - // Form Submission Delegate - - formSubmissionStarted(_formSubmission) { - this.progressBar.setValue(0); - this.showFormProgressBarAfterDelay(); - } - - formSubmissionFinished(_formSubmission) { - this.progressBar.setValue(1); - this.hideFormProgressBar(); - } - - // Private - - showVisitProgressBarAfterDelay() { - this.visitProgressBarTimeout = window.setTimeout(this.showProgressBar, this.session.progressBarDelay); - } - - hideVisitProgressBar() { - this.progressBar.hide(); - if (this.visitProgressBarTimeout != null) { - window.clearTimeout(this.visitProgressBarTimeout); - delete this.visitProgressBarTimeout; - } - } - - showFormProgressBarAfterDelay() { - if (this.formProgressBarTimeout == null) { - this.formProgressBarTimeout = window.setTimeout(this.showProgressBar, this.session.progressBarDelay); - } - } - - hideFormProgressBar() { - this.progressBar.hide(); - if (this.formProgressBarTimeout != null) { - window.clearTimeout(this.formProgressBarTimeout); - delete this.formProgressBarTimeout; - } - } - - showProgressBar = () => { - this.progressBar.show(); - } - - reload(reason) { - dispatch("turbo:reload", { detail: reason }); - - window.location.href = this.location?.toString() || window.location.href; - } - - get navigator() { - return this.session.navigator - } - } - - class CacheObserver { - selector = "[data-turbo-temporary]" - deprecatedSelector = "[data-turbo-cache=false]" - - started = false - - start() { - if (!this.started) { - this.started = true; - addEventListener("turbo:before-cache", this.removeTemporaryElements, false); - } - } - - stop() { - if (this.started) { - this.started = false; - removeEventListener("turbo:before-cache", this.removeTemporaryElements, false); - } - } - - removeTemporaryElements = (_event) => { - for (const element of this.temporaryElements) { - element.remove(); - } - } - - get temporaryElements() { - return [...document.querySelectorAll(this.selector), ...this.temporaryElementsWithDeprecation] - } - - get temporaryElementsWithDeprecation() { - const elements = document.querySelectorAll(this.deprecatedSelector); - - if (elements.length) { - console.warn( - `The ${this.deprecatedSelector} selector is deprecated and will be removed in a future version. Use ${this.selector} instead.` - ); - } - - return [...elements] - } - } - - class FrameRedirector { - constructor(session, element) { - this.session = session; - this.element = element; - this.linkInterceptor = new LinkInterceptor(this, element); - this.formSubmitObserver = new FormSubmitObserver(this, element); - } - - start() { - this.linkInterceptor.start(); - this.formSubmitObserver.start(); - } - - stop() { - this.linkInterceptor.stop(); - this.formSubmitObserver.stop(); - } - - // Link interceptor delegate - - shouldInterceptLinkClick(element, _location, _event) { - return this.#shouldRedirect(element) - } - - linkClickIntercepted(element, url, event) { - const frame = this.#findFrameElement(element); - if (frame) { - frame.delegate.linkClickIntercepted(element, url, event); - } - } - - // Form submit observer delegate - - willSubmitForm(element, submitter) { - return ( - element.closest("turbo-frame") == null && - this.#shouldSubmit(element, submitter) && - this.#shouldRedirect(element, submitter) - ) - } - - formSubmitted(element, submitter) { - const frame = this.#findFrameElement(element, submitter); - if (frame) { - frame.delegate.formSubmitted(element, submitter); - } - } - - #shouldSubmit(form, submitter) { - const action = getAction$1(form, submitter); - const meta = this.element.ownerDocument.querySelector(`meta[name="turbo-root"]`); - const rootLocation = expandURL(meta?.content ?? "/"); - - return this.#shouldRedirect(form, submitter) && locationIsVisitable(action, rootLocation) - } - - #shouldRedirect(element, submitter) { - const isNavigatable = - element instanceof HTMLFormElement - ? this.session.submissionIsNavigatable(element, submitter) - : this.session.elementIsNavigatable(element); - - if (isNavigatable) { - const frame = this.#findFrameElement(element, submitter); - return frame ? frame != element.closest("turbo-frame") : false - } else { - return false - } - } - - #findFrameElement(element, submitter) { - const id = submitter?.getAttribute("data-turbo-frame") || element.getAttribute("data-turbo-frame"); - if (id && id != "_top") { - const frame = this.element.querySelector(`#${id}:not([disabled])`); - if (frame instanceof FrameElement) { - return frame - } - } - } - } - - class History { - location - restorationIdentifier = uuid() - restorationData = {} - started = false - pageLoaded = false - currentIndex = 0 - - constructor(delegate) { - this.delegate = delegate; - } - - start() { - if (!this.started) { - addEventListener("popstate", this.onPopState, false); - addEventListener("load", this.onPageLoad, false); - this.currentIndex = history.state?.turbo?.restorationIndex || 0; - this.started = true; - this.replace(new URL(window.location.href)); - } - } - - stop() { - if (this.started) { - removeEventListener("popstate", this.onPopState, false); - removeEventListener("load", this.onPageLoad, false); - this.started = false; - } - } - - push(location, restorationIdentifier) { - this.update(history.pushState, location, restorationIdentifier); - } - - replace(location, restorationIdentifier) { - this.update(history.replaceState, location, restorationIdentifier); - } - - update(method, location, restorationIdentifier = uuid()) { - if (method === history.pushState) ++this.currentIndex; - - const state = { turbo: { restorationIdentifier, restorationIndex: this.currentIndex } }; - method.call(history, state, "", location.href); - this.location = location; - this.restorationIdentifier = restorationIdentifier; - } - - // Restoration data - - getRestorationDataForIdentifier(restorationIdentifier) { - return this.restorationData[restorationIdentifier] || {} - } - - updateRestorationData(additionalData) { - const { restorationIdentifier } = this; - const restorationData = this.restorationData[restorationIdentifier]; - this.restorationData[restorationIdentifier] = { - ...restorationData, - ...additionalData - }; - } - - // Scroll restoration - - assumeControlOfScrollRestoration() { - if (!this.previousScrollRestoration) { - this.previousScrollRestoration = history.scrollRestoration ?? "auto"; - history.scrollRestoration = "manual"; - } - } - - relinquishControlOfScrollRestoration() { - if (this.previousScrollRestoration) { - history.scrollRestoration = this.previousScrollRestoration; - delete this.previousScrollRestoration; - } - } - - // Event handlers - - onPopState = (event) => { - if (this.shouldHandlePopState()) { - const { turbo } = event.state || {}; - if (turbo) { - this.location = new URL(window.location.href); - const { restorationIdentifier, restorationIndex } = turbo; - this.restorationIdentifier = restorationIdentifier; - const direction = restorationIndex > this.currentIndex ? "forward" : "back"; - this.delegate.historyPoppedToLocationWithRestorationIdentifierAndDirection(this.location, restorationIdentifier, direction); - this.currentIndex = restorationIndex; - } - } - } - - onPageLoad = async (_event) => { - await nextMicrotask(); - this.pageLoaded = true; - } - - // Private - - shouldHandlePopState() { - // Safari dispatches a popstate event after window's load event, ignore it - return this.pageIsLoaded() - } - - pageIsLoaded() { - return this.pageLoaded || document.readyState == "complete" - } - } - - class LinkPrefetchObserver { - started = false - #prefetchedLink = null - - constructor(delegate, eventTarget) { - this.delegate = delegate; - this.eventTarget = eventTarget; - } - - start() { - if (this.started) return - - if (this.eventTarget.readyState === "loading") { - this.eventTarget.addEventListener("DOMContentLoaded", this.#enable, { once: true }); - } else { - this.#enable(); - } - } - - stop() { - if (!this.started) return - - this.eventTarget.removeEventListener("mouseenter", this.#tryToPrefetchRequest, { - capture: true, - passive: true - }); - this.eventTarget.removeEventListener("mouseleave", this.#cancelRequestIfObsolete, { - capture: true, - passive: true - }); - - this.eventTarget.removeEventListener("turbo:before-fetch-request", this.#tryToUsePrefetchedRequest, true); - this.started = false; - } - - #enable = () => { - this.eventTarget.addEventListener("mouseenter", this.#tryToPrefetchRequest, { - capture: true, - passive: true - }); - this.eventTarget.addEventListener("mouseleave", this.#cancelRequestIfObsolete, { - capture: true, - passive: true - }); - - this.eventTarget.addEventListener("turbo:before-fetch-request", this.#tryToUsePrefetchedRequest, true); - this.started = true; - } - - #tryToPrefetchRequest = (event) => { - if (getMetaContent("turbo-prefetch") === "false") return - - const target = event.target; - const isLink = target.matches && target.matches("a[href]:not([target^=_]):not([download])"); - - if (isLink && this.#isPrefetchable(target)) { - const link = target; - const location = getLocationForLink(link); - - if (this.delegate.canPrefetchRequestToLocation(link, location)) { - this.#prefetchedLink = link; - - const fetchRequest = new FetchRequest( - this, - FetchMethod.get, - location, - new URLSearchParams(), - target - ); - - prefetchCache.setLater(location.toString(), fetchRequest, this.#cacheTtl); - } - } - } - - #cancelRequestIfObsolete = (event) => { - if (event.target === this.#prefetchedLink) this.#cancelPrefetchRequest(); - } - - #cancelPrefetchRequest = () => { - prefetchCache.clear(); - this.#prefetchedLink = null; - } - - #tryToUsePrefetchedRequest = (event) => { - if (event.target.tagName !== "FORM" && event.detail.fetchOptions.method === "GET") { - const cached = prefetchCache.get(event.detail.url.toString()); - - if (cached) { - // User clicked link, use cache response - event.detail.fetchRequest = cached; - } - - prefetchCache.clear(); - } - } - - prepareRequest(request) { - const link = request.target; - - request.headers["X-Sec-Purpose"] = "prefetch"; - - const turboFrame = link.closest("turbo-frame"); - const turboFrameTarget = link.getAttribute("data-turbo-frame") || turboFrame?.getAttribute("target") || turboFrame?.id; - - if (turboFrameTarget && turboFrameTarget !== "_top") { - request.headers["Turbo-Frame"] = turboFrameTarget; - } - } - - // Fetch request interface - - requestSucceededWithResponse() {} - - requestStarted(fetchRequest) {} - - requestErrored(fetchRequest) {} - - requestFinished(fetchRequest) {} - - requestPreventedHandlingResponse(fetchRequest, fetchResponse) {} - - requestFailedWithResponse(fetchRequest, fetchResponse) {} - - get #cacheTtl() { - return Number(getMetaContent("turbo-prefetch-cache-time")) || cacheTtl - } - - #isPrefetchable(link) { - const href = link.getAttribute("href"); - - if (!href) return false - - if (unfetchableLink(link)) return false - if (linkToTheSamePage(link)) return false - if (linkOptsOut(link)) return false - if (nonSafeLink(link)) return false - if (eventPrevented(link)) return false - - return true - } - } - - const unfetchableLink = (link) => { - return link.origin !== document.location.origin || !["http:", "https:"].includes(link.protocol) || link.hasAttribute("target") - }; - - const linkToTheSamePage = (link) => { - return (link.pathname + link.search === document.location.pathname + document.location.search) || link.href.startsWith("#") - }; - - const linkOptsOut = (link) => { - if (link.getAttribute("data-turbo-prefetch") === "false") return true - if (link.getAttribute("data-turbo") === "false") return true - - const turboPrefetchParent = findClosestRecursively(link, "[data-turbo-prefetch]"); - if (turboPrefetchParent && turboPrefetchParent.getAttribute("data-turbo-prefetch") === "false") return true - - return false - }; - - const nonSafeLink = (link) => { - const turboMethod = link.getAttribute("data-turbo-method"); - if (turboMethod && turboMethod.toLowerCase() !== "get") return true - - if (isUJS(link)) return true - if (link.hasAttribute("data-turbo-confirm")) return true - if (link.hasAttribute("data-turbo-stream")) return true - - return false - }; - - const isUJS = (link) => { - return link.hasAttribute("data-remote") || link.hasAttribute("data-behavior") || link.hasAttribute("data-confirm") || link.hasAttribute("data-method") - }; - - const eventPrevented = (link) => { - const event = dispatch("turbo:before-prefetch", { target: link, cancelable: true }); - return event.defaultPrevented - }; - - class Navigator { - constructor(delegate) { - this.delegate = delegate; - } - - proposeVisit(location, options = {}) { - if (this.delegate.allowsVisitingLocationWithAction(location, options.action)) { - this.delegate.visitProposedToLocation(location, options); - } - } - - startVisit(locatable, restorationIdentifier, options = {}) { - this.stop(); - this.currentVisit = new Visit(this, expandURL(locatable), restorationIdentifier, { - referrer: this.location, - ...options - }); - this.currentVisit.start(); - } - - submitForm(form, submitter) { - this.stop(); - this.formSubmission = new FormSubmission(this, form, submitter, true); - - this.formSubmission.start(); - } - - stop() { - if (this.formSubmission) { - this.formSubmission.stop(); - delete this.formSubmission; - } - - if (this.currentVisit) { - this.currentVisit.cancel(); - delete this.currentVisit; - } - } - - get adapter() { - return this.delegate.adapter - } - - get view() { - return this.delegate.view - } - - get rootLocation() { - return this.view.snapshot.rootLocation - } - - get history() { - return this.delegate.history - } - - // Form submission delegate - - formSubmissionStarted(formSubmission) { - // Not all adapters implement formSubmissionStarted - if (typeof this.adapter.formSubmissionStarted === "function") { - this.adapter.formSubmissionStarted(formSubmission); - } - } - - async formSubmissionSucceededWithResponse(formSubmission, fetchResponse) { - if (formSubmission == this.formSubmission) { - const responseHTML = await fetchResponse.responseHTML; - if (responseHTML) { - const shouldCacheSnapshot = formSubmission.isSafe; - if (!shouldCacheSnapshot) { - this.view.clearSnapshotCache(); - } - - const { statusCode, redirected } = fetchResponse; - const action = this.#getActionForFormSubmission(formSubmission, fetchResponse); - const visitOptions = { - action, - shouldCacheSnapshot, - response: { statusCode, responseHTML, redirected } - }; - this.proposeVisit(fetchResponse.location, visitOptions); - } - } - } - - async formSubmissionFailedWithResponse(formSubmission, fetchResponse) { - const responseHTML = await fetchResponse.responseHTML; - - if (responseHTML) { - const snapshot = PageSnapshot.fromHTMLString(responseHTML); - if (fetchResponse.serverError) { - await this.view.renderError(snapshot, this.currentVisit); - } else { - await this.view.renderPage(snapshot, false, true, this.currentVisit); - } - if(!snapshot.shouldPreserveScrollPosition) { - this.view.scrollToTop(); - } - this.view.clearSnapshotCache(); - } - } - - formSubmissionErrored(formSubmission, error) { - console.error(error); - } - - formSubmissionFinished(formSubmission) { - // Not all adapters implement formSubmissionFinished - if (typeof this.adapter.formSubmissionFinished === "function") { - this.adapter.formSubmissionFinished(formSubmission); - } - } - - // Visit delegate - - visitStarted(visit) { - this.delegate.visitStarted(visit); - } - - visitCompleted(visit) { - this.delegate.visitCompleted(visit); - delete this.currentVisit; - } - - locationWithActionIsSamePage(location, action) { - const anchor = getAnchor(location); - const currentAnchor = getAnchor(this.view.lastRenderedLocation); - const isRestorationToTop = action === "restore" && typeof anchor === "undefined"; - - return ( - action !== "replace" && - getRequestURL(location) === getRequestURL(this.view.lastRenderedLocation) && - (isRestorationToTop || (anchor != null && anchor !== currentAnchor)) - ) - } - - visitScrolledToSamePageLocation(oldURL, newURL) { - this.delegate.visitScrolledToSamePageLocation(oldURL, newURL); - } - - // Visits - - get location() { - return this.history.location - } - - get restorationIdentifier() { - return this.history.restorationIdentifier - } - - #getActionForFormSubmission(formSubmission, fetchResponse) { - const { submitter, formElement } = formSubmission; - return getVisitAction(submitter, formElement) || this.#getDefaultAction(fetchResponse) - } - - #getDefaultAction(fetchResponse) { - const sameLocationRedirect = fetchResponse.redirected && fetchResponse.location.href === this.location?.href; - return sameLocationRedirect ? "replace" : "advance" - } - } - - const PageStage = { - initial: 0, - loading: 1, - interactive: 2, - complete: 3 - }; - - class PageObserver { - stage = PageStage.initial - started = false - - constructor(delegate) { - this.delegate = delegate; - } - - start() { - if (!this.started) { - if (this.stage == PageStage.initial) { - this.stage = PageStage.loading; - } - document.addEventListener("readystatechange", this.interpretReadyState, false); - addEventListener("pagehide", this.pageWillUnload, false); - this.started = true; - } - } - - stop() { - if (this.started) { - document.removeEventListener("readystatechange", this.interpretReadyState, false); - removeEventListener("pagehide", this.pageWillUnload, false); - this.started = false; - } - } - - interpretReadyState = () => { - const { readyState } = this; - if (readyState == "interactive") { - this.pageIsInteractive(); - } else if (readyState == "complete") { - this.pageIsComplete(); - } - } - - pageIsInteractive() { - if (this.stage == PageStage.loading) { - this.stage = PageStage.interactive; - this.delegate.pageBecameInteractive(); - } - } - - pageIsComplete() { - this.pageIsInteractive(); - if (this.stage == PageStage.interactive) { - this.stage = PageStage.complete; - this.delegate.pageLoaded(); - } - } - - pageWillUnload = () => { - this.delegate.pageWillUnload(); - } - - get readyState() { - return document.readyState - } - } - - class ScrollObserver { - started = false - - constructor(delegate) { - this.delegate = delegate; - } - - start() { - if (!this.started) { - addEventListener("scroll", this.onScroll, false); - this.onScroll(); - this.started = true; - } - } - - stop() { - if (this.started) { - removeEventListener("scroll", this.onScroll, false); - this.started = false; - } - } - - onScroll = () => { - this.updatePosition({ x: window.pageXOffset, y: window.pageYOffset }); - } - - // Private - - updatePosition(position) { - this.delegate.scrollPositionChanged(position); - } - } - - class StreamMessageRenderer { - render({ fragment }) { - Bardo.preservingPermanentElements(this, getPermanentElementMapForFragment(fragment), () => { - withAutofocusFromFragment(fragment, () => { - withPreservedFocus(() => { - document.documentElement.appendChild(fragment); - }); - }); - }); - } - - // Bardo delegate - - enteringBardo(currentPermanentElement, newPermanentElement) { - newPermanentElement.replaceWith(currentPermanentElement.cloneNode(true)); - } - - leavingBardo() {} - } - - function getPermanentElementMapForFragment(fragment) { - const permanentElementsInDocument = queryPermanentElementsAll(document.documentElement); - const permanentElementMap = {}; - for (const permanentElementInDocument of permanentElementsInDocument) { - const { id } = permanentElementInDocument; - - for (const streamElement of fragment.querySelectorAll("turbo-stream")) { - const elementInStream = getPermanentElementById(streamElement.templateElement.content, id); - - if (elementInStream) { - permanentElementMap[id] = [permanentElementInDocument, elementInStream]; - } - } - } - - return permanentElementMap - } - - async function withAutofocusFromFragment(fragment, callback) { - const generatedID = `turbo-stream-autofocus-${uuid()}`; - const turboStreams = fragment.querySelectorAll("turbo-stream"); - const elementWithAutofocus = firstAutofocusableElementInStreams(turboStreams); - let willAutofocusId = null; - - if (elementWithAutofocus) { - if (elementWithAutofocus.id) { - willAutofocusId = elementWithAutofocus.id; - } else { - willAutofocusId = generatedID; - } - - elementWithAutofocus.id = willAutofocusId; - } - - callback(); - await nextRepaint(); - - const hasNoActiveElement = document.activeElement == null || document.activeElement == document.body; - - if (hasNoActiveElement && willAutofocusId) { - const elementToAutofocus = document.getElementById(willAutofocusId); - - if (elementIsFocusable(elementToAutofocus)) { - elementToAutofocus.focus(); - } - if (elementToAutofocus && elementToAutofocus.id == generatedID) { - elementToAutofocus.removeAttribute("id"); - } - } - } - - async function withPreservedFocus(callback) { - const [activeElementBeforeRender, activeElementAfterRender] = await around(callback, () => document.activeElement); - - const restoreFocusTo = activeElementBeforeRender && activeElementBeforeRender.id; - - if (restoreFocusTo) { - const elementToFocus = document.getElementById(restoreFocusTo); - - if (elementIsFocusable(elementToFocus) && elementToFocus != activeElementAfterRender) { - elementToFocus.focus(); - } - } - } - - function firstAutofocusableElementInStreams(nodeListOfStreamElements) { - for (const streamElement of nodeListOfStreamElements) { - const elementWithAutofocus = queryAutofocusableElement(streamElement.templateElement.content); - - if (elementWithAutofocus) return elementWithAutofocus - } - - return null - } - - class StreamObserver { - sources = new Set() - #started = false - - constructor(delegate) { - this.delegate = delegate; - } - - start() { - if (!this.#started) { - this.#started = true; - addEventListener("turbo:before-fetch-response", this.inspectFetchResponse, false); - } - } - - stop() { - if (this.#started) { - this.#started = false; - removeEventListener("turbo:before-fetch-response", this.inspectFetchResponse, false); - } - } - - connectStreamSource(source) { - if (!this.streamSourceIsConnected(source)) { - this.sources.add(source); - source.addEventListener("message", this.receiveMessageEvent, false); - } - } - - disconnectStreamSource(source) { - if (this.streamSourceIsConnected(source)) { - this.sources.delete(source); - source.removeEventListener("message", this.receiveMessageEvent, false); - } - } - - streamSourceIsConnected(source) { - return this.sources.has(source) - } - - inspectFetchResponse = (event) => { - const response = fetchResponseFromEvent(event); - if (response && fetchResponseIsStream(response)) { - event.preventDefault(); - this.receiveMessageResponse(response); - } - } - - receiveMessageEvent = (event) => { - if (this.#started && typeof event.data == "string") { - this.receiveMessageHTML(event.data); - } - } - - async receiveMessageResponse(response) { - const html = await response.responseHTML; - if (html) { - this.receiveMessageHTML(html); - } - } - - receiveMessageHTML(html) { - this.delegate.receivedMessageFromStream(StreamMessage.wrap(html)); - } - } - - function fetchResponseFromEvent(event) { - const fetchResponse = event.detail?.fetchResponse; - if (fetchResponse instanceof FetchResponse) { - return fetchResponse - } - } - - function fetchResponseIsStream(response) { - const contentType = response.contentType ?? ""; - return contentType.startsWith(StreamMessage.contentType) - } - - class ErrorRenderer extends Renderer { - static renderElement(currentElement, newElement) { - const { documentElement, body } = document; - - documentElement.replaceChild(newElement, body); - } - - async render() { - this.replaceHeadAndBody(); - this.activateScriptElements(); - } - - replaceHeadAndBody() { - const { documentElement, head } = document; - documentElement.replaceChild(this.newHead, head); - this.renderElement(this.currentElement, this.newElement); - } - - activateScriptElements() { - for (const replaceableElement of this.scriptElements) { - const parentNode = replaceableElement.parentNode; - if (parentNode) { - const element = activateScriptElement(replaceableElement); - parentNode.replaceChild(element, replaceableElement); - } - } - } - - get newHead() { - return this.newSnapshot.headSnapshot.element - } - - get scriptElements() { - return document.documentElement.querySelectorAll("script") - } - } - - 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); - if (!isRecentRequest && !this.navigator.currentVisit) { - this.visit(url, { action: "replace", shouldCacheSnapshot: false }); - } - } - - connectStreamSource(source) { - this.streamObserver.connectStreamSource(source); - } - - disconnectStreamSource(source) { - this.streamObserver.disconnectStreamSource(source); - } - - renderStreamMessage(message) { - this.streamMessageRenderer.render(StreamMessage.wrap(message)); - } - - clearCache() { - this.view.clearSnapshotCache(); - } - - setProgressBarDelay(delay) { - 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) - ) - } - - // 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 is performing a full page visit due to turbo-visit-control.` - ); - - await this.#visitResponse(fetchResponse.response); - } - - #willHandleFrameMissingFromResponse(fetchResponse) { - this.element.setAttribute("complete", ""); - - const response = fetchResponse.response; - const visit = async (url, options) => { - if (url instanceof Response) { - this.#visitResponse(url); - } else { - session.visit(url, options); - } - }; - - const event = dispatch("turbo:frame-missing", { - target: this.element, - detail: { response, visit }, - cancelable: true - }); - - return !event.defaultPrevented - } - - #handleFrameMissingFromResponse(fetchResponse) { - this.view.missing(); - this.#throwFrameMissingError(fetchResponse); - } - - #throwFrameMissingError(fetchResponse) { - const message = `The response (${fetchResponse.statusCode}) did not contain the expected and will be ignored. To perform a full page visit instead, set turbo-visit-control to reload.`; - throw new TurboFrameMissingError(message) - } - - async #visitResponse(response) { - const wrapped = new FetchResponse(response); - const responseHTML = await wrapped.responseHTML; - const { location, redirected, statusCode } = wrapped; - - return session.visit(location, { response: { redirected, statusCode, responseHTML } }) - } - - #findFrameElement(element, submitter) { - const id = getAttribute("data-turbo-frame", submitter, element) || this.element.getAttribute("target"); - return getFrameElementById(id) ?? this.element - } - - async extractForeignFrameElement(container) { - let element; - const id = CSS.escape(this.id); - - try { - element = activateElement(container.querySelector(`turbo-frame#${id}`), this.sourceURL); - if (element) { - return element - } - - element = activateElement(container.querySelector(`turbo-frame[src][recurse~=${id}]`), this.sourceURL); - if (element) { - await element.loaded; - return await this.extractForeignFrameElement(element) - } - } catch (error) { - console.error(error); - return new FrameElement() - } - - return null - } - - #formActionIsVisitable(form, submitter) { - const action = getAction$1(form, submitter); - - return locationIsVisitable(expandURL(action), this.rootLocation) - } - - #shouldInterceptNavigation(element, submitter) { - const id = getAttribute("data-turbo-frame", submitter, element) || this.element.getAttribute("target"); - - if (element instanceof HTMLFormElement && !this.#formActionIsVisitable(element, submitter)) { - return false - } - - if (!this.enabled || id == "_top") { - return false - } - - if (id) { - const frameElement = getFrameElementById(id); - if (frameElement) { - return !frameElement.disabled - } - } - - if (!session.elementIsNavigatable(element)) { - return false - } - - if (submitter && !session.elementIsNavigatable(submitter)) { - return false - } - - return true - } - - // Computed properties - - get id() { - return this.element.id - } - - get enabled() { - return !this.element.disabled - } - - get sourceURL() { - if (this.element.src) { - return this.element.src - } - } - - set sourceURL(sourceURL) { - this.#ignoringChangesToAttribute("src", () => { - this.element.src = sourceURL ?? null; - }); - } - - get loadingStyle() { - return this.element.loading - } - - get isLoading() { - return this.formSubmission !== undefined || this.#resolveVisitPromise() !== undefined - } - - get complete() { - return this.element.hasAttribute("complete") - } - - set complete(value) { - if (value) { - this.element.setAttribute("complete", ""); - } else { - this.element.removeAttribute("complete"); - } - } - - get isActive() { - return this.element.isActive && this.#connected - } - - get rootLocation() { - const meta = this.element.ownerDocument.querySelector(`meta[name="turbo-root"]`); - const root = meta?.content ?? "/"; - return expandURL(root) - } - - #isIgnoringChangesTo(attributeName) { - return this.#ignoredAttributes.has(attributeName) - } - - #ignoringChangesToAttribute(attributeName, callback) { - this.#ignoredAttributes.add(attributeName); - callback(); - this.#ignoredAttributes.delete(attributeName); - } - - #withCurrentNavigationElement(element, callback) { - this.currentNavigationElement = element; - callback(); - delete this.currentNavigationElement; - } - } - - function getFrameElementById(id) { - if (id != null) { - const element = document.getElementById(id); - if (element instanceof FrameElement) { - return element - } - } - } - - function activateElement(element, currentURL) { - if (element) { - const src = element.getAttribute("src"); - if (src != null && currentURL != null && urlsAreEqual(src, currentURL)) { - throw new Error(`Matching element has a source URL which references itself`) - } - if (element.ownerDocument !== document) { - element = document.importNode(element, true); - } - - if (element instanceof FrameElement) { - element.connectedCallback(); - element.disconnectedCallback(); - return element - } - } - } - - const StreamActions = { - after() { - this.targetElements.forEach((e) => e.parentElement?.insertBefore(this.templateContent, e.nextSibling)); - }, - - append() { - this.removeDuplicateTargetChildren(); - this.targetElements.forEach((e) => e.append(this.templateContent)); - }, - - before() { - this.targetElements.forEach((e) => e.parentElement?.insertBefore(this.templateContent, e)); - }, - - prepend() { - this.removeDuplicateTargetChildren(); - this.targetElements.forEach((e) => e.prepend(this.templateContent)); - }, - - remove() { - this.targetElements.forEach((e) => e.remove()); - }, - - replace() { - const method = this.getAttribute("method"); - - this.targetElements.forEach((targetElement) => { - if (method === "morph") { - morphElements(targetElement, this.templateContent); - } else { - targetElement.replaceWith(this.templateContent); - } - }); - }, - - update() { - const method = this.getAttribute("method"); - - this.targetElements.forEach((targetElement) => { - if (method === "morph") { - morphChildren(targetElement, this.templateContent); - } else { - targetElement.innerHTML = ""; - targetElement.append(this.templateContent); - } - }); - }, - - refresh() { - session.refresh(this.baseURI, this.requestId); - } - }; - - //