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.
- *
- *
- *
- *
- */
- 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(/