From d271dda54f8cdcc061f3556bbc4a38826a7eef53 Mon Sep 17 00:00:00 2001 From: "lenaick.moreira" Date: Mon, 27 Apr 2026 12:21:17 +0200 Subject: [PATCH] =?UTF-8?q?N=C2=B09341=20-=20Update=20Turbo=20third-party?= =?UTF-8?q?=20lib=20to=20v8.0.21=20min.=20to=20fix=20security=20issue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- node_modules/.package-lock.json | 9 +- node_modules/@hotwired/turbo/CHANGELOG.md | 3 + node_modules/@hotwired/turbo/README.md | 2 +- .../@hotwired/turbo/dist/turbo.es2017-esm.js | 3542 +++++++++------- .../@hotwired/turbo/dist/turbo.es2017-umd.js | 3548 ++++++++++------- node_modules/@hotwired/turbo/package.json | 13 +- package-lock.json | 17 +- package.json | 2 +- 8 files changed, 4248 insertions(+), 2888 deletions(-) create mode 100644 node_modules/@hotwired/turbo/CHANGELOG.md diff --git a/node_modules/.package-lock.json b/node_modules/.package-lock.json index 9a6f709c38..719ffa9c65 100644 --- a/node_modules/.package-lock.json +++ b/node_modules/.package-lock.json @@ -14,11 +14,12 @@ "integrity": "sha512-Rzj90wbZQnNzazqzoiu5HzMEMdqMJLUVFOo699sinTXrZRm1aB5iX2HTiK2VlPnH4M6u8yYnJ7CebOyamfWlqw==" }, "node_modules/@hotwired/turbo": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/@hotwired/turbo/-/turbo-8.0.5.tgz", - "integrity": "sha512-TdZDA7fxVQ2ZycygvpnzjGPmFq4sO/E2QVg+2em/sJ3YTSsIWVEis8HmWlumz+c9DjWcUkcCuB+muF08TInpAQ==", + "version": "8.0.23", + "resolved": "https://registry.npmjs.org/@hotwired/turbo/-/turbo-8.0.23.tgz", + "integrity": "sha512-GZ7cijxEZ6Ig71u7rD6LHaRv/wcE/hNsc+nEfiWOkLNqUgLOwo5MNGWOy5ZV9ZUDSiQx1no7YxjTNnT4O6//cQ==", + "license": "MIT", "engines": { - "node": ">= 14" + "node": ">= 18" } }, "node_modules/@orchidjs/sifter": { diff --git a/node_modules/@hotwired/turbo/CHANGELOG.md b/node_modules/@hotwired/turbo/CHANGELOG.md new file mode 100644 index 0000000000..85e46a882b --- /dev/null +++ b/node_modules/@hotwired/turbo/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +Please see [our GitHub "Releases" page](https://github.com/hotwired/turbo/releases). diff --git a/node_modules/@hotwired/turbo/README.md b/node_modules/@hotwired/turbo/README.md index ed74ef6da7..1bd7ee7eff 100644 --- a/node_modules/@hotwired/turbo/README.md +++ b/node_modules/@hotwired/turbo/README.md @@ -15,4 +15,4 @@ Read more on [turbo.hotwired.dev](https://turbo.hotwired.dev). Please read [CONTRIBUTING.md](./CONTRIBUTING.md). -© 2024 37signals LLC. +© 2026 37signals LLC. diff --git a/node_modules/@hotwired/turbo/dist/turbo.es2017-esm.js b/node_modules/@hotwired/turbo/dist/turbo.es2017-esm.js index 2a43bcb3a5..086945c48b 100644 --- a/node_modules/@hotwired/turbo/dist/turbo.es2017-esm.js +++ b/node_modules/@hotwired/turbo/dist/turbo.es2017-esm.js @@ -1,104 +1,7 @@ /*! -Turbo 8.0.5 -Copyright © 2024 37signals LLC +Turbo 8.0.23 +Copyright © 2026 37signals LLC */ -/** - * The MIT License (MIT) - * - * Copyright (c) 2019 Javan Makhmali - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - */ - -(function (prototype) { - if (typeof prototype.requestSubmit == "function") return - - prototype.requestSubmit = function (submitter) { - if (submitter) { - validateSubmitter(submitter, this); - submitter.click(); - } else { - submitter = document.createElement("input"); - submitter.type = "submit"; - submitter.hidden = true; - this.appendChild(submitter); - submitter.click(); - this.removeChild(submitter); - } - }; - - function validateSubmitter(submitter, form) { - submitter instanceof HTMLElement || raise(TypeError, "parameter 1 is not of type 'HTMLElement'"); - submitter.type == "submit" || raise(TypeError, "The specified element is not a submit button"); - submitter.form == form || - raise(DOMException, "The specified element is not owned by this form element", "NotFoundError"); - } - - function raise(errorConstructor, message, name) { - throw new errorConstructor("Failed to execute 'requestSubmit' on 'HTMLFormElement': " + message + ".", name) - } -})(HTMLFormElement.prototype); - -const submittersByForm = new WeakMap(); - -function findSubmitterFromClickTarget(target) { - const element = target instanceof Element ? target : target instanceof Node ? target.parentElement : null; - const candidate = element ? element.closest("input, button") : null; - return candidate?.type == "submit" ? candidate : null -} - -function clickCaptured(event) { - const submitter = findSubmitterFromClickTarget(event.target); - - if (submitter && submitter.form) { - submittersByForm.set(submitter.form, submitter); - } -} - -(function () { - if ("submitter" in Event.prototype) return - - let prototype = window.Event.prototype; - // Certain versions of Safari 15 have a bug where they won't - // populate the submitter. This hurts TurboDrive's enable/disable detection. - // See https://bugs.webkit.org/show_bug.cgi?id=229660 - if ("SubmitEvent" in window) { - const prototypeOfSubmitEvent = window.SubmitEvent.prototype; - - if (/Apple Computer/.test(navigator.vendor) && !("submitter" in prototypeOfSubmitEvent)) { - prototype = prototypeOfSubmitEvent; - } else { - return // polyfill not needed - } - } - - addEventListener("click", clickCaptured, true); - - Object.defineProperty(prototype, "submitter", { - get() { - if (this.type == "submit" && this.target instanceof HTMLFormElement) { - return submittersByForm.get(this.target) - } - } - }); -})(); - const FrameLoadingStyle = { eager: "eager", lazy: "lazy" @@ -192,6 +95,10 @@ class FrameElement extends HTMLElement { } } + get shouldReloadWithMorph() { + return this.src && this.refresh === "morph" + } + /** * Determines if the element is loading */ @@ -289,136 +196,27 @@ function frameLoadingStyleFromString(style) { } } -function expandURL(locatable) { - return new URL(locatable.toString(), document.baseURI) -} - -function getAnchor(url) { - let anchorMatch; - if (url.hash) { - return url.hash.slice(1) - // eslint-disable-next-line no-cond-assign - } else if ((anchorMatch = url.href.match(/#(.*)$/))) { - return anchorMatch[1] - } -} - -function getAction$1(form, submitter) { - const action = submitter?.getAttribute("formaction") || form.getAttribute("action") || form.action; - - return expandURL(action) -} - -function getExtension(url) { - return (getLastPathComponent(url).match(/\.[^.]*$/) || [])[0] || "" -} - -function isHTML(url) { - return !!getExtension(url).match(/^(?:|\.(?:htm|html|xhtml|php))$/) -} - -function isPrefixedBy(baseURL, url) { - const prefix = getPrefix(url); - return baseURL.href === expandURL(prefix).href || baseURL.href.startsWith(prefix) -} - -function locationIsVisitable(location, rootLocation) { - return isPrefixedBy(location, rootLocation) && isHTML(location) -} - -function getRequestURL(url) { - const anchor = getAnchor(url); - return anchor != null ? url.href.slice(0, -(anchor.length + 1)) : url.href -} - -function toCacheKey(url) { - return getRequestURL(url) -} - -function urlsAreEqual(left, right) { - return expandURL(left).href == expandURL(right).href -} - -function getPathComponents(url) { - return url.pathname.split("/").slice(1) -} - -function getLastPathComponent(url) { - return getPathComponents(url).slice(-1)[0] -} - -function getPrefix(url) { - return addTrailingSlash(url.origin + url.pathname) -} - -function addTrailingSlash(value) { - return value.endsWith("/") ? value : value + "/" -} - -class FetchResponse { - constructor(response) { - this.response = response; - } - - get succeeded() { - return this.response.ok - } - - get failed() { - return !this.succeeded - } - - get clientError() { - return this.statusCode >= 400 && this.statusCode <= 499 - } - - get serverError() { - return this.statusCode >= 500 && this.statusCode <= 599 - } - - get redirected() { - return this.response.redirected - } - - get location() { - return expandURL(this.response.url) - } - - get isHTML() { - return this.contentType && this.contentType.match(/^(?:text\/([^\s;,]+\b)?html|application\/xhtml\+xml)\b/) - } - - get statusCode() { - return this.response.status - } - - get contentType() { - return this.header("Content-Type") - } - - get responseText() { - return this.response.clone().text() - } - - get responseHTML() { - if (this.isHTML) { - return this.response.clone().text() - } else { - return Promise.resolve(undefined) - } - } - - header(name) { - return this.response.headers.get(name) - } -} +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 = getMetaContent("csp-nonce"); + const cspNonce = getCspNonce(); if (cspNonce) { createdScriptElement.nonce = cspNonce; } @@ -458,6 +256,11 @@ function dispatch(eventName, { target, cancelable, detail } = {}) { return event } +function cancelEvent(event) { + event.preventDefault(); + event.stopImmediatePropagation(); +} + function nextRepaint() { if (document.visibilityState === "hidden") { return nextEventLoopTick() @@ -474,10 +277,6 @@ function nextEventLoopTick() { return new Promise((resolve) => setTimeout(() => resolve(), 0)) } -function nextMicrotask() { - return Promise.resolve() -} - function parseHTMLDocument(html = "") { return new DOMParser().parseFromString(html, "text/html") } @@ -506,7 +305,7 @@ function uuid() { } else if (i == 19) { return (Math.floor(Math.random() * 4) + 8).toString(16) } else { - return Math.floor(Math.random() * 15).toString(16) + return Math.floor(Math.random() * 16).toString(16) } }) .join("") @@ -586,6 +385,15 @@ function getMetaContent(name) { return element && element.content } +function getCspNonce() { + const element = getMetaElement("csp-nonce"); + + if (element) { + const { nonce, content } = element; + return nonce == "" ? content : nonce + } +} + function setMetaContent(name, content) { let element = getMetaElement(name); @@ -646,11 +454,16 @@ function doesNotTargetIFrame(name) { } function findLinkFromClickTarget(target) { - return findClosestRecursively(target, "a[href]:not([target^=_]):not([download])") -} + const link = findClosestRecursively(target, "a[href], a[xlink\\:href]"); -function getLocationForLink(link) { - return expandURL(link.getAttribute("href") || "") + if (!link) return null + if (link.href.startsWith("#")) return null + if (link.hasAttribute("download")) return null + + const linkTarget = link.getAttribute("target"); + if (linkTarget && linkTarget !== "_self") return null + + return link } function debounce(fn, delay) { @@ -663,6 +476,171 @@ function debounce(fn, delay) { } } +const submitter = { + "aria-disabled": { + beforeSubmit: submitter => { + submitter.setAttribute("aria-disabled", "true"); + submitter.addEventListener("click", cancelEvent); + }, + + afterSubmit: submitter => { + submitter.removeAttribute("aria-disabled"); + submitter.removeEventListener("click", cancelEvent); + } + }, + + "disabled": { + beforeSubmit: submitter => submitter.disabled = true, + afterSubmit: submitter => submitter.disabled = false + } +}; + +class Config { + #submitter = null + + constructor(config) { + Object.assign(this, config); + } + + get submitter() { + return this.#submitter + } + + set submitter(value) { + this.#submitter = submitter[value] || value; + } +} + +const forms = new Config({ + mode: "on", + submitter: "disabled" +}); + +const config = { + drive, + forms +}; + +function expandURL(locatable) { + return new URL(locatable.toString(), document.baseURI) +} + +function getAnchor(url) { + let anchorMatch; + if (url.hash) { + return url.hash.slice(1) + // eslint-disable-next-line no-cond-assign + } else if ((anchorMatch = url.href.match(/#(.*)$/))) { + return anchorMatch[1] + } +} + +function getAction$1(form, submitter) { + const action = submitter?.getAttribute("formaction") || form.getAttribute("action") || form.action; + + return expandURL(action) +} + +function getExtension(url) { + return (getLastPathComponent(url).match(/\.[^.]*$/) || [])[0] || "" +} + +function isPrefixedBy(baseURL, url) { + const prefix = addTrailingSlash(url.origin + url.pathname); + return addTrailingSlash(baseURL.href) === prefix || baseURL.href.startsWith(prefix) +} + +function locationIsVisitable(location, rootLocation) { + return isPrefixedBy(location, rootLocation) && !config.drive.unvisitableExtensions.has(getExtension(location)) +} + +function getLocationForLink(link) { + return expandURL(link.getAttribute("href") || "") +} + +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 addTrailingSlash(value) { + return value.endsWith("/") ? value : value + "/" +} + +class FetchResponse { + constructor(response) { + this.response = response; + } + + get succeeded() { + return this.response.ok + } + + get failed() { + return !this.succeeded + } + + get clientError() { + return this.statusCode >= 400 && this.statusCode <= 499 + } + + get serverError() { + return this.statusCode >= 500 && this.statusCode <= 599 + } + + get redirected() { + return this.response.redirected + } + + get location() { + return expandURL(this.response.url) + } + + get isHTML() { + return this.contentType && this.contentType.match(/^(?:text\/([^\s;,]+\b)?html|application\/xhtml\+xml)\b/) + } + + get statusCode() { + return this.response.status + } + + get contentType() { + return this.header("Content-Type") + } + + get responseText() { + return this.response.clone().text() + } + + get responseHTML() { + if (this.isHTML) { + return this.response.clone().text() + } else { + return Promise.resolve(undefined) + } + } + + header(name) { + return this.response.headers.get(name) + } +} + class LimitedSet extends Set { constructor(maxSize) { super(); @@ -681,15 +659,13 @@ class LimitedSet extends Set { 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, { + return window.fetch(url, { ...options, headers: modifiedHeaders }) @@ -997,35 +973,113 @@ function importStreamElements(fragment) { return fragment } -const PREFETCH_DELAY = 100; +const identity = key => key; -class PrefetchCache { - #prefetchTimeout = null - #prefetched = null +class LRUCache { + keys = [] + entries = {} + #toCacheKey - get(url) { - if (this.#prefetched && this.#prefetched.url === url && this.#prefetched.expire > Date.now()) { - return this.#prefetched.request + constructor(size, toCacheKey = identity) { + this.size = size; + this.#toCacheKey = toCacheKey; + } + + has(key) { + return this.#toCacheKey(key) in this.entries + } + + get(key) { + if (this.has(key)) { + const entry = this.read(key); + this.touch(key); + return entry } } - setLater(url, request, ttl) { - this.clear(); - - this.#prefetchTimeout = setTimeout(() => { - request.perform(); - this.set(url, request, ttl); - this.#prefetchTimeout = null; - }, PREFETCH_DELAY); - } - - set(url, request, ttl) { - this.#prefetched = { url, request, expire: new Date(new Date().getTime() + ttl) }; + put(key, entry) { + this.write(key, entry); + this.touch(key); + return entry } clear() { + for (const key of Object.keys(this.entries)) { + this.evict(key); + } + } + + // Private + + read(key) { + return this.entries[this.#toCacheKey(key)] + } + + write(key, entry) { + this.entries[this.#toCacheKey(key)] = entry; + } + + touch(key) { + key = this.#toCacheKey(key); + 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)) { + this.evict(key); + } + } + + evict(key) { + delete this.entries[key]; + } +} + +const PREFETCH_DELAY = 100; + +class PrefetchCache extends LRUCache { + #prefetchTimeout = null + #maxAges = {} + + constructor(size = 1, prefetchDelay = PREFETCH_DELAY) { + super(size, toCacheKey); + this.prefetchDelay = prefetchDelay; + } + + putLater(url, request, ttl) { + this.#prefetchTimeout = setTimeout(() => { + request.perform(); + this.put(url, request, ttl); + this.#prefetchTimeout = null; + }, this.prefetchDelay); + } + + put(url, request, ttl = cacheTtl) { + super.put(url, request); + this.#maxAges[toCacheKey(url)] = new Date(new Date().getTime() + ttl); + } + + clear() { + super.clear(); if (this.#prefetchTimeout) clearTimeout(this.#prefetchTimeout); - this.#prefetched = null; + } + + evict(key) { + super.evict(key); + delete this.#maxAges[key]; + } + + has(key) { + if (super.has(key)) { + const maxAge = this.#maxAges[toCacheKey(key)]; + + return maxAge && maxAge > Date.now() + } else { + return false + } } } @@ -1044,7 +1098,7 @@ const FormSubmissionState = { class FormSubmission { state = FormSubmissionState.initialized - static confirmMethod(message, _element, _submitter) { + static confirmMethod(message) { return Promise.resolve(confirm(message)) } @@ -1100,7 +1154,11 @@ class FormSubmission { const confirmationMessage = getAttribute("data-turbo-confirm", this.submitter, this.formElement); if (typeof confirmationMessage === "string") { - const answer = await FormSubmission.confirmMethod(confirmationMessage, this.formElement, this.submitter); + const confirmMethod = typeof config.forms.confirm === "function" ? + config.forms.confirm : + FormSubmission.confirmMethod; + + const answer = await confirmMethod(confirmationMessage, this.formElement, this.submitter); if (!answer) { return } @@ -1138,7 +1196,7 @@ class FormSubmission { requestStarted(_request) { this.state = FormSubmissionState.waiting; - this.submitter?.setAttribute("disabled", ""); + if (this.submitter) config.forms.submitter.beforeSubmit(this.submitter); this.setSubmitsWith(); markAsBusy(this.formElement); dispatch("turbo:submit-start", { @@ -1184,7 +1242,7 @@ class FormSubmission { requestFinished(_request) { this.state = FormSubmissionState.stopped; - this.submitter?.removeAttribute("disabled"); + if (this.submitter) config.forms.submitter.afterSubmit(this.submitter); this.resetSubmitterText(); clearBusyState(this.formElement); dispatch("turbo:submit-end", { @@ -1421,8 +1479,8 @@ class View { scrollToAnchor(anchor) { const element = this.snapshot.getElementForAnchor(anchor); if (element) { - this.scrollToElement(element); this.focusElement(element); + this.scrollToElement(element); } else { this.scrollToPosition({ x: 0, y: 0 }); } @@ -1774,12 +1832,16 @@ function createPlaceholderForPermanentElement(permanentElement) { class Renderer { #activeElement = null - constructor(currentSnapshot, newSnapshot, renderElement, isPreview, willRender = true) { + 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 = renderElement; + this.renderElement = this.constructor.renderElement; this.promise = new Promise((resolve, reject) => (this.resolvingFunctions = { resolve, reject })); } @@ -1944,6 +2006,1514 @@ function readScrollBehavior(value, defaultValue) { } } +/** + * @typedef {object} ConfigHead + * + * @property {'merge' | 'append' | 'morph' | 'none'} [style] + * @property {boolean} [block] + * @property {boolean} [ignore] + * @property {function(Element): boolean} [shouldPreserve] + * @property {function(Element): boolean} [shouldReAppend] + * @property {function(Element): boolean} [shouldRemove] + * @property {function(Element, {added: Node[], kept: Element[], removed: Element[]}): void} [afterHeadMorphed] + */ + +/** + * @typedef {object} ConfigCallbacks + * + * @property {function(Node): boolean} [beforeNodeAdded] + * @property {function(Node): void} [afterNodeAdded] + * @property {function(Element, Node): boolean} [beforeNodeMorphed] + * @property {function(Element, Node): void} [afterNodeMorphed] + * @property {function(Element): boolean} [beforeNodeRemoved] + * @property {function(Element): void} [afterNodeRemoved] + * @property {function(string, Element, "update" | "remove"): boolean} [beforeAttributeUpdated] + */ + +/** + * @typedef {object} Config + * + * @property {'outerHTML' | 'innerHTML'} [morphStyle] + * @property {boolean} [ignoreActive] + * @property {boolean} [ignoreActiveValue] + * @property {boolean} [restoreFocus] + * @property {ConfigCallbacks} [callbacks] + * @property {ConfigHead} [head] + */ + +/** + * @typedef {function} NoOp + * + * @returns {void} + */ + +/** + * @typedef {object} ConfigHeadInternal + * + * @property {'merge' | 'append' | 'morph' | 'none'} style + * @property {boolean} [block] + * @property {boolean} [ignore] + * @property {(function(Element): boolean) | NoOp} shouldPreserve + * @property {(function(Element): boolean) | NoOp} shouldReAppend + * @property {(function(Element): boolean) | NoOp} shouldRemove + * @property {(function(Element, {added: Node[], kept: Element[], removed: Element[]}): void) | NoOp} afterHeadMorphed + */ + +/** + * @typedef {object} ConfigCallbacksInternal + * + * @property {(function(Node): boolean) | NoOp} beforeNodeAdded + * @property {(function(Node): void) | NoOp} afterNodeAdded + * @property {(function(Node, Node): boolean) | NoOp} beforeNodeMorphed + * @property {(function(Node, Node): void) | NoOp} afterNodeMorphed + * @property {(function(Node): boolean) | NoOp} beforeNodeRemoved + * @property {(function(Node): void) | NoOp} afterNodeRemoved + * @property {(function(string, Element, "update" | "remove"): boolean) | NoOp} beforeAttributeUpdated + */ + +/** + * @typedef {object} ConfigInternal + * + * @property {'outerHTML' | 'innerHTML'} morphStyle + * @property {boolean} [ignoreActive] + * @property {boolean} [ignoreActiveValue] + * @property {boolean} [restoreFocus] + * @property {ConfigCallbacksInternal} callbacks + * @property {ConfigHeadInternal} head + */ + +/** + * @typedef {Object} IdSets + * @property {Set} persistentIds + * @property {Map>} idMap + */ + +/** + * @typedef {Function} Morph + * + * @param {Element | Document} oldNode + * @param {Element | Node | HTMLCollection | Node[] | string | null} newContent + * @param {Config} [config] + * @returns {undefined | Node[]} + */ + +// base IIFE to define idiomorph +/** + * + * @type {{defaults: ConfigInternal, morph: Morph}} + */ +var Idiomorph = (function () { + + /** + * @typedef {object} MorphContext + * + * @property {Element} target + * @property {Element} newContent + * @property {ConfigInternal} config + * @property {ConfigInternal['morphStyle']} morphStyle + * @property {ConfigInternal['ignoreActive']} ignoreActive + * @property {ConfigInternal['ignoreActiveValue']} ignoreActiveValue + * @property {ConfigInternal['restoreFocus']} restoreFocus + * @property {Map>} idMap + * @property {Set} persistentIds + * @property {ConfigInternal['callbacks']} callbacks + * @property {ConfigInternal['head']} head + * @property {HTMLDivElement} pantry + * @property {Element[]} activeElementAndParents + */ + + //============================================================================= + // AND NOW IT BEGINS... + //============================================================================= + + const noOp = () => {}; + /** + * Default configuration values, updatable by users now + * @type {ConfigInternal} + */ + const defaults = { + morphStyle: "outerHTML", + callbacks: { + beforeNodeAdded: noOp, + afterNodeAdded: noOp, + beforeNodeMorphed: noOp, + afterNodeMorphed: noOp, + beforeNodeRemoved: noOp, + afterNodeRemoved: noOp, + beforeAttributeUpdated: noOp, + }, + head: { + style: "merge", + shouldPreserve: (elt) => elt.getAttribute("im-preserve") === "true", + shouldReAppend: (elt) => elt.getAttribute("im-re-append") === "true", + shouldRemove: noOp, + afterHeadMorphed: noOp, + }, + restoreFocus: true, + }; + + /** + * Core idiomorph function for morphing one DOM tree to another + * + * @param {Element | Document} oldNode + * @param {Element | Node | HTMLCollection | Node[] | string | null} newContent + * @param {Config} [config] + * @returns {Promise | Node[]} + */ + function morph(oldNode, newContent, config = {}) { + oldNode = normalizeElement(oldNode); + const newNode = normalizeParent(newContent); + const ctx = createMorphContext(oldNode, newNode, config); + + const morphedNodes = saveAndRestoreFocus(ctx, () => { + return withHeadBlocking( + ctx, + oldNode, + newNode, + /** @param {MorphContext} ctx */ (ctx) => { + if (ctx.morphStyle === "innerHTML") { + morphChildren(ctx, oldNode, newNode); + return Array.from(oldNode.childNodes); + } else { + return morphOuterHTML(ctx, oldNode, newNode); + } + }, + ); + }); + + ctx.pantry.remove(); + return morphedNodes; + } + + /** + * Morph just the outerHTML of the oldNode to the newContent + * We have to be careful because the oldNode could have siblings which need to be untouched + * @param {MorphContext} ctx + * @param {Element} oldNode + * @param {Element} newNode + * @returns {Node[]} + */ + function morphOuterHTML(ctx, oldNode, newNode) { + const oldParent = normalizeParent(oldNode); + morphChildren( + ctx, + oldParent, + newNode, + // these two optional params are the secret sauce + oldNode, // start point for iteration + oldNode.nextSibling, // end point for iteration + ); + // this is safe even with siblings, because normalizeParent returns a SlicedParentNode if needed. + return Array.from(oldParent.childNodes); + } + + /** + * @param {MorphContext} ctx + * @param {Function} fn + * @returns {Promise | Node[]} + */ + function saveAndRestoreFocus(ctx, fn) { + if (!ctx.config.restoreFocus) return fn(); + let activeElement = + /** @type {HTMLInputElement|HTMLTextAreaElement|null} */ ( + document.activeElement + ); + + // don't bother if the active element is not an input or textarea + if ( + !( + activeElement instanceof HTMLInputElement || + activeElement instanceof HTMLTextAreaElement + ) + ) { + return fn(); + } + + const { id: activeElementId, selectionStart, selectionEnd } = activeElement; + + const results = fn(); + + if ( + activeElementId && + activeElementId !== document.activeElement?.getAttribute("id") + ) { + activeElement = ctx.target.querySelector(`[id="${activeElementId}"]`); + activeElement?.focus(); + } + if (activeElement && !activeElement.selectionEnd && selectionEnd) { + activeElement.setSelectionRange(selectionStart, selectionEnd); + } + + return results; + } + + const morphChildren = (function () { + /** + * This is the core algorithm for matching up children. The idea is to use id sets to try to match up + * nodes as faithfully as possible. We greedily match, which allows us to keep the algorithm fast, but + * by using id sets, we are able to better match up with content deeper in the DOM. + * + * Basic algorithm: + * - for each node in the new content: + * - search self and siblings for an id set match, falling back to a soft match + * - if match found + * - remove any nodes up to the match: + * - pantry persistent nodes + * - delete the rest + * - morph the match + * - elsif no match found, and node is persistent + * - find its match by querying the old root (future) and pantry (past) + * - move it and its children here + * - morph it + * - else + * - create a new node from scratch as a last result + * + * @param {MorphContext} ctx the merge context + * @param {Element} oldParent the old content that we are merging the new content into + * @param {Element} newParent the parent element of the new content + * @param {Node|null} [insertionPoint] the point in the DOM we start morphing at (defaults to first child) + * @param {Node|null} [endPoint] the point in the DOM we stop morphing at (defaults to after last child) + */ + function morphChildren( + ctx, + oldParent, + newParent, + insertionPoint = null, + endPoint = null, + ) { + // normalize + if ( + oldParent instanceof HTMLTemplateElement && + newParent instanceof HTMLTemplateElement + ) { + // @ts-ignore we can pretend the DocumentFragment is an Element + oldParent = oldParent.content; + // @ts-ignore ditto + newParent = newParent.content; + } + insertionPoint ||= oldParent.firstChild; + + // run through all the new content + for (const newChild of newParent.childNodes) { + // once we reach the end of the old parent content skip to the end and insert the rest + if (insertionPoint && insertionPoint != endPoint) { + const bestMatch = findBestMatch( + ctx, + newChild, + insertionPoint, + endPoint, + ); + if (bestMatch) { + // if the node to morph is not at the insertion point then remove/move up to it + if (bestMatch !== insertionPoint) { + removeNodesBetween(ctx, insertionPoint, bestMatch); + } + morphNode(bestMatch, newChild, ctx); + insertionPoint = bestMatch.nextSibling; + continue; + } + } + + // if the matching node is elsewhere in the original content + if (newChild instanceof Element) { + // we can pretend the id is non-null because the next `.has` line will reject it if not + const newChildId = /** @type {String} */ ( + newChild.getAttribute("id") + ); + if (ctx.persistentIds.has(newChildId)) { + // move it and all its children here and morph + const movedChild = moveBeforeById( + oldParent, + newChildId, + insertionPoint, + ctx, + ); + morphNode(movedChild, newChild, ctx); + insertionPoint = movedChild.nextSibling; + continue; + } + } + + // last resort: insert the new node from scratch + const insertedNode = createNode( + oldParent, + newChild, + insertionPoint, + ctx, + ); + // could be null if beforeNodeAdded prevented insertion + if (insertedNode) { + insertionPoint = insertedNode.nextSibling; + } + } + + // remove any remaining old nodes that didn't match up with new content + while (insertionPoint && insertionPoint != endPoint) { + const tempNode = insertionPoint; + insertionPoint = insertionPoint.nextSibling; + removeNode(ctx, tempNode); + } + } + + /** + * This performs the action of inserting a new node while handling situations where the node contains + * elements with persistent ids and possible state info we can still preserve by moving in and then morphing + * + * @param {Element} oldParent + * @param {Node} newChild + * @param {Node|null} insertionPoint + * @param {MorphContext} ctx + * @returns {Node|null} + */ + function createNode(oldParent, newChild, insertionPoint, ctx) { + if (ctx.callbacks.beforeNodeAdded(newChild) === false) return null; + if (ctx.idMap.has(newChild)) { + // node has children with ids with possible state so create a dummy elt of same type and apply full morph algorithm + const newEmptyChild = document.createElement( + /** @type {Element} */ (newChild).tagName, + ); + oldParent.insertBefore(newEmptyChild, insertionPoint); + morphNode(newEmptyChild, newChild, ctx); + ctx.callbacks.afterNodeAdded(newEmptyChild); + return newEmptyChild; + } else { + // optimisation: no id state to preserve so we can just insert a clone of the newChild and its descendants + const newClonedChild = document.importNode(newChild, true); // importNode to not mutate newParent + oldParent.insertBefore(newClonedChild, insertionPoint); + ctx.callbacks.afterNodeAdded(newClonedChild); + return newClonedChild; + } + } + + //============================================================================= + // Matching Functions + //============================================================================= + const findBestMatch = (function () { + /** + * Scans forward from the startPoint to the endPoint looking for a match + * for the node. It looks for an id set match first, then a soft match. + * We abort softmatching if we find two future soft matches, to reduce churn. + * @param {Node} node + * @param {MorphContext} ctx + * @param {Node | null} startPoint + * @param {Node | null} endPoint + * @returns {Node | null} + */ + function findBestMatch(ctx, node, startPoint, endPoint) { + let softMatch = null; + let nextSibling = node.nextSibling; + let siblingSoftMatchCount = 0; + + let cursor = startPoint; + while (cursor && cursor != endPoint) { + // soft matching is a prerequisite for id set matching + if (isSoftMatch(cursor, node)) { + if (isIdSetMatch(ctx, cursor, node)) { + return cursor; // found an id set match, we're done! + } + + // we haven't yet saved a soft match fallback + if (softMatch === null) { + // the current soft match will hard match something else in the future, leave it + if (!ctx.idMap.has(cursor)) { + // save this as the fallback if we get through the loop without finding a hard match + softMatch = cursor; + } + } + } + if ( + softMatch === null && + nextSibling && + isSoftMatch(cursor, nextSibling) + ) { + // The next new node has a soft match with this node, so + // increment the count of future soft matches + siblingSoftMatchCount++; + nextSibling = nextSibling.nextSibling; + + // If there are two future soft matches, block soft matching for this node to allow + // future siblings to soft match. This is to reduce churn in the DOM when an element + // is prepended. + if (siblingSoftMatchCount >= 2) { + softMatch = undefined; + } + } + + // if the current node contains active element, stop looking for better future matches, + // because if one is found, this node will be moved to the pantry, reparenting it and thus losing focus + // @ts-ignore pretend cursor is Element rather than Node, we're just testing for array inclusion + if (ctx.activeElementAndParents.includes(cursor)) break; + + cursor = cursor.nextSibling; + } + + return softMatch || null; + } + + /** + * + * @param {MorphContext} ctx + * @param {Node} oldNode + * @param {Node} newNode + * @returns {boolean} + */ + function isIdSetMatch(ctx, oldNode, newNode) { + let oldSet = ctx.idMap.get(oldNode); + let newSet = ctx.idMap.get(newNode); + + if (!newSet || !oldSet) return false; + + for (const id of oldSet) { + // a potential match is an id in the new and old nodes that + // has not already been merged into the DOM + // But the newNode content we call this on has not been + // merged yet and we don't allow duplicate IDs so it is simple + if (newSet.has(id)) { + return true; + } + } + return false; + } + + /** + * + * @param {Node} oldNode + * @param {Node} newNode + * @returns {boolean} + */ + function isSoftMatch(oldNode, newNode) { + // ok to cast: if one is not element, `id` and `tagName` will be undefined and we'll just compare that. + const oldElt = /** @type {Element} */ (oldNode); + const newElt = /** @type {Element} */ (newNode); + + return ( + oldElt.nodeType === newElt.nodeType && + oldElt.tagName === newElt.tagName && + // If oldElt has an `id` with possible state and it doesn't match newElt.id then avoid morphing. + // We'll still match an anonymous node with an IDed newElt, though, because if it got this far, + // its not persistent, and new nodes can't have any hidden state. + // We can't use .id because of form input shadowing, and we can't count on .getAttribute's presence because it could be a document-fragment + (!oldElt.getAttribute?.("id") || + oldElt.getAttribute?.("id") === newElt.getAttribute?.("id")) + ); + } + + return findBestMatch; + })(); + + //============================================================================= + // DOM Manipulation Functions + //============================================================================= + + /** + * Gets rid of an unwanted DOM node; strategy depends on nature of its reuse: + * - Persistent nodes will be moved to the pantry for later reuse + * - Other nodes will have their hooks called, and then are removed + * @param {MorphContext} ctx + * @param {Node} node + */ + function removeNode(ctx, node) { + // are we going to id set match this later? + if (ctx.idMap.has(node)) { + // skip callbacks and move to pantry + moveBefore(ctx.pantry, node, null); + } else { + // remove for realsies + if (ctx.callbacks.beforeNodeRemoved(node) === false) return; + node.parentNode?.removeChild(node); + ctx.callbacks.afterNodeRemoved(node); + } + } + + /** + * Remove nodes between the start and end nodes + * @param {MorphContext} ctx + * @param {Node} startInclusive + * @param {Node} endExclusive + * @returns {Node|null} + */ + function removeNodesBetween(ctx, startInclusive, endExclusive) { + /** @type {Node | null} */ + let cursor = startInclusive; + // remove nodes until the endExclusive node + while (cursor && cursor !== endExclusive) { + let tempNode = /** @type {Node} */ (cursor); + cursor = cursor.nextSibling; + removeNode(ctx, tempNode); + } + return cursor; + } + + /** + * Search for an element by id within the document and pantry, and move it using moveBefore. + * + * @param {Element} parentNode - The parent node to which the element will be moved. + * @param {string} id - The ID of the element to be moved. + * @param {Node | null} after - The reference node to insert the element before. + * If `null`, the element is appended as the last child. + * @param {MorphContext} ctx + * @returns {Element} The found element + */ + function moveBeforeById(parentNode, id, after, ctx) { + const target = + /** @type {Element} - will always be found */ + ( + // ctx.target.id unsafe because of form input shadowing + // ctx.target could be a document fragment which doesn't have `getAttribute` + (ctx.target.getAttribute?.("id") === id && ctx.target) || + ctx.target.querySelector(`[id="${id}"]`) || + ctx.pantry.querySelector(`[id="${id}"]`) + ); + removeElementFromAncestorsIdMaps(target, ctx); + moveBefore(parentNode, target, after); + return target; + } + + /** + * Removes an element from its ancestors' id maps. This is needed when an element is moved from the + * "future" via `moveBeforeId`. Otherwise, its erstwhile ancestors could be mistakenly moved to the + * pantry rather than being deleted, preventing their removal hooks from being called. + * + * @param {Element} element - element to remove from its ancestors' id maps + * @param {MorphContext} ctx + */ + function removeElementFromAncestorsIdMaps(element, ctx) { + // we know id is non-null String, because this function is only called on elements with ids + const id = /** @type {String} */ (element.getAttribute("id")); + /** @ts-ignore - safe to loop in this way **/ + while ((element = element.parentNode)) { + let idSet = ctx.idMap.get(element); + if (idSet) { + idSet.delete(id); + if (!idSet.size) { + ctx.idMap.delete(element); + } + } + } + } + + /** + * Moves an element before another element within the same parent. + * Uses the proposed `moveBefore` API if available (and working), otherwise falls back to `insertBefore`. + * This is essentialy a forward-compat wrapper. + * + * @param {Element} parentNode - The parent node containing the after element. + * @param {Node} element - The element to be moved. + * @param {Node | null} after - The reference node to insert `element` before. + * If `null`, `element` is appended as the last child. + */ + function moveBefore(parentNode, element, after) { + // @ts-ignore - use proposed moveBefore feature + if (parentNode.moveBefore) { + try { + // @ts-ignore - use proposed moveBefore feature + parentNode.moveBefore(element, after); + } catch (e) { + // fall back to insertBefore as some browsers may fail on moveBefore when trying to move Dom disconnected nodes to pantry + parentNode.insertBefore(element, after); + } + } else { + parentNode.insertBefore(element, after); + } + } + + return morphChildren; + })(); + + //============================================================================= + // Single Node Morphing Code + //============================================================================= + const morphNode = (function () { + /** + * @param {Node} oldNode root node to merge content into + * @param {Node} newContent new content to merge + * @param {MorphContext} ctx the merge context + * @returns {Node | null} the element that ended up in the DOM + */ + function morphNode(oldNode, newContent, ctx) { + if (ctx.ignoreActive && oldNode === document.activeElement) { + // don't morph focused element + return null; + } + + if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) { + return oldNode; + } + + if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) ; else if ( + oldNode instanceof HTMLHeadElement && + ctx.head.style !== "morph" + ) { + // ok to cast: if newContent wasn't also a , it would've got caught in the `!isSoftMatch` branch above + handleHeadElement( + oldNode, + /** @type {HTMLHeadElement} */ (newContent), + ctx, + ); + } else { + morphAttributes(oldNode, newContent, ctx); + if (!ignoreValueOfActiveElement(oldNode, ctx)) { + // @ts-ignore newContent can be a node here because .firstChild will be null + morphChildren(ctx, oldNode, newContent); + } + } + ctx.callbacks.afterNodeMorphed(oldNode, newContent); + return oldNode; + } + + /** + * syncs the oldNode to the newNode, copying over all attributes and + * inner element state from the newNode to the oldNode + * + * @param {Node} oldNode the node to copy attributes & state to + * @param {Node} newNode the node to copy attributes & state from + * @param {MorphContext} ctx the merge context + */ + function morphAttributes(oldNode, newNode, ctx) { + let type = newNode.nodeType; + + // if is an element type, sync the attributes from the + // new node into the new node + if (type === 1 /* element type */) { + const oldElt = /** @type {Element} */ (oldNode); + const newElt = /** @type {Element} */ (newNode); + + const oldAttributes = oldElt.attributes; + const newAttributes = newElt.attributes; + for (const newAttribute of newAttributes) { + if (ignoreAttribute(newAttribute.name, oldElt, "update", ctx)) { + continue; + } + if (oldElt.getAttribute(newAttribute.name) !== newAttribute.value) { + oldElt.setAttribute(newAttribute.name, newAttribute.value); + } + } + // iterate backwards to avoid skipping over items when a delete occurs + for (let i = oldAttributes.length - 1; 0 <= i; i--) { + const oldAttribute = oldAttributes[i]; + + // toAttributes is a live NamedNodeMap, so iteration+mutation is unsafe + // e.g. custom element attribute callbacks can remove other attributes + if (!oldAttribute) continue; + + if (!newElt.hasAttribute(oldAttribute.name)) { + if (ignoreAttribute(oldAttribute.name, oldElt, "remove", ctx)) { + continue; + } + oldElt.removeAttribute(oldAttribute.name); + } + } + + if (!ignoreValueOfActiveElement(oldElt, ctx)) { + syncInputValue(oldElt, newElt, ctx); + } + } + + // sync text nodes + if (type === 8 /* comment */ || type === 3 /* text */) { + if (oldNode.nodeValue !== newNode.nodeValue) { + oldNode.nodeValue = newNode.nodeValue; + } + } + } + + /** + * NB: many bothans died to bring us information: + * + * https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js + * https://github.com/choojs/nanomorph/blob/master/lib/morph.jsL113 + * + * @param {Element} oldElement the element to sync the input value to + * @param {Element} newElement the element to sync the input value from + * @param {MorphContext} ctx the merge context + */ + function syncInputValue(oldElement, newElement, ctx) { + if ( + oldElement instanceof HTMLInputElement && + newElement instanceof HTMLInputElement && + newElement.type !== "file" + ) { + let newValue = newElement.value; + let oldValue = oldElement.value; + + // sync boolean attributes + syncBooleanAttribute(oldElement, newElement, "checked", ctx); + syncBooleanAttribute(oldElement, newElement, "disabled", ctx); + + if (!newElement.hasAttribute("value")) { + if (!ignoreAttribute("value", oldElement, "remove", ctx)) { + oldElement.value = ""; + oldElement.removeAttribute("value"); + } + } else if (oldValue !== newValue) { + if (!ignoreAttribute("value", oldElement, "update", ctx)) { + oldElement.setAttribute("value", newValue); + oldElement.value = newValue; + } + } + // TODO: QUESTION(1cg): this used to only check `newElement` unlike the other branches -- why? + // did I break something? + } else if ( + oldElement instanceof HTMLOptionElement && + newElement instanceof HTMLOptionElement + ) { + syncBooleanAttribute(oldElement, newElement, "selected", ctx); + } else if ( + oldElement instanceof HTMLTextAreaElement && + newElement instanceof HTMLTextAreaElement + ) { + let newValue = newElement.value; + let oldValue = oldElement.value; + if (ignoreAttribute("value", oldElement, "update", ctx)) { + return; + } + if (newValue !== oldValue) { + oldElement.value = newValue; + } + if ( + oldElement.firstChild && + oldElement.firstChild.nodeValue !== newValue + ) { + oldElement.firstChild.nodeValue = newValue; + } + } + } + + /** + * @param {Element} oldElement element to write the value to + * @param {Element} newElement element to read the value from + * @param {string} attributeName the attribute name + * @param {MorphContext} ctx the merge context + */ + function syncBooleanAttribute(oldElement, newElement, attributeName, ctx) { + // @ts-ignore this function is only used on boolean attrs that are reflected as dom properties + const newLiveValue = newElement[attributeName], + // @ts-ignore ditto + oldLiveValue = oldElement[attributeName]; + if (newLiveValue !== oldLiveValue) { + const ignoreUpdate = ignoreAttribute( + attributeName, + oldElement, + "update", + ctx, + ); + if (!ignoreUpdate) { + // update attribute's associated DOM property + // @ts-ignore this function is only used on boolean attrs that are reflected as dom properties + oldElement[attributeName] = newElement[attributeName]; + } + if (newLiveValue) { + if (!ignoreUpdate) { + // https://developer.mozilla.org/en-US/docs/Glossary/Boolean/HTML + // this is the correct way to set a boolean attribute to "true" + oldElement.setAttribute(attributeName, ""); + } + } else { + if (!ignoreAttribute(attributeName, oldElement, "remove", ctx)) { + oldElement.removeAttribute(attributeName); + } + } + } + } + + /** + * @param {string} attr the attribute to be mutated + * @param {Element} element the element that is going to be updated + * @param {"update" | "remove"} updateType + * @param {MorphContext} ctx the merge context + * @returns {boolean} true if the attribute should be ignored, false otherwise + */ + function ignoreAttribute(attr, element, updateType, ctx) { + if ( + attr === "value" && + ctx.ignoreActiveValue && + element === document.activeElement + ) { + return true; + } + return ( + ctx.callbacks.beforeAttributeUpdated(attr, element, updateType) === + false + ); + } + + /** + * @param {Node} possibleActiveElement + * @param {MorphContext} ctx + * @returns {boolean} + */ + function ignoreValueOfActiveElement(possibleActiveElement, ctx) { + return ( + !!ctx.ignoreActiveValue && + possibleActiveElement === document.activeElement && + possibleActiveElement !== document.body + ); + } + + return morphNode; + })(); + + //============================================================================= + // Head Management Functions + //============================================================================= + /** + * @param {MorphContext} ctx + * @param {Element} oldNode + * @param {Element} newNode + * @param {function} callback + * @returns {Node[] | Promise} + */ + function withHeadBlocking(ctx, oldNode, newNode, callback) { + if (ctx.head.block) { + const oldHead = oldNode.querySelector("head"); + const newHead = newNode.querySelector("head"); + if (oldHead && newHead) { + const promises = handleHeadElement(oldHead, newHead, ctx); + // when head promises resolve, proceed ignoring the head tag + return Promise.all(promises).then(() => { + const newCtx = Object.assign(ctx, { + head: { + block: false, + ignore: true, + }, + }); + return callback(newCtx); + }); + } + } + // just proceed if we not head blocking + return callback(ctx); + } + + /** + * The HEAD tag can be handled specially, either w/ a 'merge' or 'append' style + * + * @param {Element} oldHead + * @param {Element} newHead + * @param {MorphContext} ctx + * @returns {Promise[]} + */ + function handleHeadElement(oldHead, newHead, ctx) { + let added = []; + let removed = []; + let preserved = []; + let nodesToAppend = []; + + // put all new head elements into a Map, by their outerHTML + let srcToNewHeadNodes = new Map(); + for (const newHeadChild of newHead.children) { + srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild); + } + + // for each elt in the current head + for (const currentHeadElt of oldHead.children) { + // If the current head element is in the map + let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML); + let isReAppended = ctx.head.shouldReAppend(currentHeadElt); + let isPreserved = ctx.head.shouldPreserve(currentHeadElt); + if (inNewContent || isPreserved) { + if (isReAppended) { + // remove the current version and let the new version replace it and re-execute + removed.push(currentHeadElt); + } else { + // this element already exists and should not be re-appended, so remove it from + // the new content map, preserving it in the DOM + srcToNewHeadNodes.delete(currentHeadElt.outerHTML); + preserved.push(currentHeadElt); + } + } else { + if (ctx.head.style === "append") { + // we are appending and this existing element is not new content + // so if and only if it is marked for re-append do we do anything + if (isReAppended) { + removed.push(currentHeadElt); + nodesToAppend.push(currentHeadElt); + } + } else { + // if this is a merge, we remove this content since it is not in the new head + if (ctx.head.shouldRemove(currentHeadElt) !== false) { + removed.push(currentHeadElt); + } + } + } + } + + // Push the remaining new head elements in the Map into the + // nodes to append to the head tag + nodesToAppend.push(...srcToNewHeadNodes.values()); + + let promises = []; + for (const newNode of nodesToAppend) { + // TODO: This could theoretically be null, based on type + let newElt = /** @type {ChildNode} */ ( + document.createRange().createContextualFragment(newNode.outerHTML) + .firstChild + ); + if (ctx.callbacks.beforeNodeAdded(newElt) !== false) { + if ( + ("href" in newElt && newElt.href) || + ("src" in newElt && newElt.src) + ) { + /** @type {(result?: any) => void} */ let resolve; + let promise = new Promise(function (_resolve) { + resolve = _resolve; + }); + newElt.addEventListener("load", function () { + resolve(); + }); + promises.push(promise); + } + oldHead.appendChild(newElt); + ctx.callbacks.afterNodeAdded(newElt); + added.push(newElt); + } + } + + // remove all removed elements, after we have appended the new elements to avoid + // additional network requests for things like style sheets + for (const removedElement of removed) { + if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) { + oldHead.removeChild(removedElement); + ctx.callbacks.afterNodeRemoved(removedElement); + } + } + + ctx.head.afterHeadMorphed(oldHead, { + added: added, + kept: preserved, + removed: removed, + }); + return promises; + } + + //============================================================================= + // Create Morph Context Functions + //============================================================================= + const createMorphContext = (function () { + /** + * + * @param {Element} oldNode + * @param {Element} newContent + * @param {Config} config + * @returns {MorphContext} + */ + function createMorphContext(oldNode, newContent, config) { + const { persistentIds, idMap } = createIdMaps(oldNode, newContent); + + const mergedConfig = mergeDefaults(config); + const morphStyle = mergedConfig.morphStyle || "outerHTML"; + if (!["innerHTML", "outerHTML"].includes(morphStyle)) { + throw `Do not understand how to morph style ${morphStyle}`; + } + + return { + target: oldNode, + newContent: newContent, + config: mergedConfig, + morphStyle: morphStyle, + ignoreActive: mergedConfig.ignoreActive, + ignoreActiveValue: mergedConfig.ignoreActiveValue, + restoreFocus: mergedConfig.restoreFocus, + idMap: idMap, + persistentIds: persistentIds, + pantry: createPantry(), + activeElementAndParents: createActiveElementAndParents(oldNode), + callbacks: mergedConfig.callbacks, + head: mergedConfig.head, + }; + } + + /** + * Deep merges the config object and the Idiomorph.defaults object to + * produce a final configuration object + * @param {Config} config + * @returns {ConfigInternal} + */ + function mergeDefaults(config) { + let finalConfig = Object.assign({}, defaults); + + // copy top level stuff into final config + Object.assign(finalConfig, config); + + // copy callbacks into final config (do this to deep merge the callbacks) + finalConfig.callbacks = Object.assign( + {}, + defaults.callbacks, + config.callbacks, + ); + + // copy head config into final config (do this to deep merge the head) + finalConfig.head = Object.assign({}, defaults.head, config.head); + + return finalConfig; + } + + /** + * @returns {HTMLDivElement} + */ + function createPantry() { + const pantry = document.createElement("div"); + pantry.hidden = true; + document.body.insertAdjacentElement("afterend", pantry); + return pantry; + } + + /** + * @param {Element} oldNode + * @returns {Element[]} + */ + function createActiveElementAndParents(oldNode) { + /** @type {Element[]} */ + let activeElementAndParents = []; + let elt = document.activeElement; + if (elt?.tagName !== "BODY" && oldNode.contains(elt)) { + while (elt) { + activeElementAndParents.push(elt); + if (elt === oldNode) break; + elt = elt.parentElement; + } + } + return activeElementAndParents; + } + + /** + * Returns all elements with an ID contained within the root element and its descendants + * + * @param {Element} root + * @returns {Element[]} + */ + function findIdElements(root) { + let elements = Array.from(root.querySelectorAll("[id]")); + // root could be a document fragment which doesn't have `getAttribute` + if (root.getAttribute?.("id")) { + elements.push(root); + } + return elements; + } + + /** + * A bottom-up algorithm that populates a map of Element -> IdSet. + * The idSet for a given element is the set of all IDs contained within its subtree. + * As an optimzation, we filter these IDs through the given list of persistent IDs, + * because we don't need to bother considering IDed elements that won't be in the new content. + * + * @param {Map>} idMap + * @param {Set} persistentIds + * @param {Element} root + * @param {Element[]} elements + */ + function populateIdMapWithTree(idMap, persistentIds, root, elements) { + for (const elt of elements) { + // we can pretend id is non-null String, because the .has line will reject it immediately if not + const id = /** @type {String} */ (elt.getAttribute("id")); + if (persistentIds.has(id)) { + /** @type {Element|null} */ + let current = elt; + // walk up the parent hierarchy of that element, adding the id + // of element to the parent's id set + while (current) { + let idSet = idMap.get(current); + // if the id set doesn't exist, create it and insert it in the map + if (idSet == null) { + idSet = new Set(); + idMap.set(current, idSet); + } + idSet.add(id); + + if (current === root) break; + current = current.parentElement; + } + } + } + } + + /** + * This function computes a map of nodes to all ids contained within that node (inclusive of the + * node). This map can be used to ask if two nodes have intersecting sets of ids, which allows + * for a looser definition of "matching" than tradition id matching, and allows child nodes + * to contribute to a parent nodes matching. + * + * @param {Element} oldContent the old content that will be morphed + * @param {Element} newContent the new content to morph to + * @returns {IdSets} + */ + function createIdMaps(oldContent, newContent) { + const oldIdElements = findIdElements(oldContent); + const newIdElements = findIdElements(newContent); + + const persistentIds = createPersistentIds(oldIdElements, newIdElements); + + /** @type {Map>} */ + let idMap = new Map(); + populateIdMapWithTree(idMap, persistentIds, oldContent, oldIdElements); + + /** @ts-ignore - if newContent is a duck-typed parent, pass its single child node as the root to halt upwards iteration */ + const newRoot = newContent.__idiomorphRoot || newContent; + populateIdMapWithTree(idMap, persistentIds, newRoot, newIdElements); + + return { persistentIds, idMap }; + } + + /** + * This function computes the set of ids that persist between the two contents excluding duplicates + * + * @param {Element[]} oldIdElements + * @param {Element[]} newIdElements + * @returns {Set} + */ + function createPersistentIds(oldIdElements, newIdElements) { + let duplicateIds = new Set(); + + /** @type {Map} */ + let oldIdTagNameMap = new Map(); + for (const { id, tagName } of oldIdElements) { + if (oldIdTagNameMap.has(id)) { + duplicateIds.add(id); + } else { + oldIdTagNameMap.set(id, tagName); + } + } + + let persistentIds = new Set(); + for (const { id, tagName } of newIdElements) { + if (persistentIds.has(id)) { + duplicateIds.add(id); + } else if (oldIdTagNameMap.get(id) === tagName) { + persistentIds.add(id); + } + // skip if tag types mismatch because its not possible to morph one tag into another + } + + for (const id of duplicateIds) { + persistentIds.delete(id); + } + return persistentIds; + } + + return createMorphContext; + })(); + + //============================================================================= + // HTML Normalization Functions + //============================================================================= + const { normalizeElement, normalizeParent } = (function () { + /** @type {WeakSet} */ + const generatedByIdiomorph = new WeakSet(); + + /** + * + * @param {Element | Document} content + * @returns {Element} + */ + function normalizeElement(content) { + if (content instanceof Document) { + return content.documentElement; + } else { + return content; + } + } + + /** + * + * @param {null | string | Node | HTMLCollection | Node[] | Document & {generatedByIdiomorph:boolean}} newContent + * @returns {Element} + */ + function normalizeParent(newContent) { + if (newContent == null) { + return document.createElement("div"); // dummy parent element + } else if (typeof newContent === "string") { + return normalizeParent(parseContent(newContent)); + } else if ( + generatedByIdiomorph.has(/** @type {Element} */ (newContent)) + ) { + // the template tag created by idiomorph parsing can serve as a dummy parent + return /** @type {Element} */ (newContent); + } else if (newContent instanceof Node) { + if (newContent.parentNode) { + // we can't use the parent directly because newContent may have siblings + // that we don't want in the morph, and reparenting might be expensive (TODO is it?), + // so instead we create a fake parent node that only sees a slice of its children. + /** @type {Element} */ + return /** @type {any} */ (new SlicedParentNode(newContent)); + } else { + // a single node is added as a child to a dummy parent + const dummyParent = document.createElement("div"); + dummyParent.append(newContent); + return dummyParent; + } + } else { + // all nodes in the array or HTMLElement collection are consolidated under + // a single dummy parent element + const dummyParent = document.createElement("div"); + for (const elt of [...newContent]) { + dummyParent.append(elt); + } + return dummyParent; + } + } + + /** + * A fake duck-typed parent element to wrap a single node, without actually reparenting it. + * This is useful because the node may have siblings that we don't want in the morph, and it may also be moved + * or replaced with one or more elements during the morph. This class effectively allows us a window into + * a slice of a node's children. + * "If it walks like a duck, and quacks like a duck, then it must be a duck!" -- James Whitcomb Riley (1849–1916) + */ + class SlicedParentNode { + /** @param {Node} node */ + constructor(node) { + this.originalNode = node; + this.realParentNode = /** @type {Element} */ (node.parentNode); + this.previousSibling = node.previousSibling; + this.nextSibling = node.nextSibling; + } + + /** @returns {Node[]} */ + get childNodes() { + // return slice of realParent's current childNodes, based on previousSibling and nextSibling + const nodes = []; + let cursor = this.previousSibling + ? this.previousSibling.nextSibling + : this.realParentNode.firstChild; + while (cursor && cursor != this.nextSibling) { + nodes.push(cursor); + cursor = cursor.nextSibling; + } + return nodes; + } + + /** + * @param {string} selector + * @returns {Element[]} + */ + querySelectorAll(selector) { + return this.childNodes.reduce((results, node) => { + if (node instanceof Element) { + if (node.matches(selector)) results.push(node); + const nodeList = node.querySelectorAll(selector); + for (let i = 0; i < nodeList.length; i++) { + results.push(nodeList[i]); + } + } + return results; + }, /** @type {Element[]} */ ([])); + } + + /** + * @param {Node} node + * @param {Node} referenceNode + * @returns {Node} + */ + insertBefore(node, referenceNode) { + return this.realParentNode.insertBefore(node, referenceNode); + } + + /** + * @param {Node} node + * @param {Node} referenceNode + * @returns {Node} + */ + moveBefore(node, referenceNode) { + // @ts-ignore - use new moveBefore feature + return this.realParentNode.moveBefore(node, referenceNode); + } + + /** + * for later use with populateIdMapWithTree to halt upwards iteration + * @returns {Node} + */ + get __idiomorphRoot() { + return this.originalNode; + } + } + + /** + * + * @param {string} newContent + * @returns {Node | null | DocumentFragment} + */ + function parseContent(newContent) { + let parser = new DOMParser(); + + // remove svgs to avoid false-positive matches on head, etc. + let contentWithSvgsRemoved = newContent.replace( + /]*>|>)([\s\S]*?)<\/svg>/gim, + "", + ); + + // if the newContent contains a html, head or body tag, we can simply parse it w/o wrapping + if ( + contentWithSvgsRemoved.match(/<\/html>/) || + contentWithSvgsRemoved.match(/<\/head>/) || + contentWithSvgsRemoved.match(/<\/body>/) + ) { + let content = parser.parseFromString(newContent, "text/html"); + // if it is a full HTML document, return the document itself as the parent container + if (contentWithSvgsRemoved.match(/<\/html>/)) { + generatedByIdiomorph.add(content); + return content; + } else { + // otherwise return the html element as the parent container + let htmlElement = content.firstChild; + if (htmlElement) { + generatedByIdiomorph.add(htmlElement); + } + return htmlElement; + } + } else { + // if it is partial HTML, wrap it in a template tag to provide a parent element and also to help + // deal with touchy tags like tr, tbody, etc. + let responseDoc = parser.parseFromString( + "", + "text/html", + ); + let content = /** @type {HTMLTemplateElement} */ ( + responseDoc.body.querySelector("template") + ).content; + generatedByIdiomorph.add(content); + return content; + } + } + + return { normalizeElement, normalizeParent }; + })(); + + //============================================================================= + // This is what ends up becoming the Idiomorph global object + //============================================================================= + return { + morph, + defaults, + }; +})(); + +/** + * Morph the state of the currentElement based on the attributes and contents of + * the newElement. Morphing may dispatch turbo:before-morph-element, + * turbo:before-morph-attribute, and turbo:morph-element events. + * + * @param currentElement Element destination of morphing changes + * @param newElement Element source of morphing changes + */ +function morphElements(currentElement, newElement, { callbacks, ...options } = {}) { + Idiomorph.morph(currentElement, newElement, { + ...options, + callbacks: new DefaultIdiomorphCallbacks(callbacks) + }); +} + +/** + * Morph the child elements of the currentElement based on the child elements of + * the newElement. Morphing children may dispatch turbo:before-morph-element, + * turbo:before-morph-attribute, and turbo:morph-element events. + * + * @param currentElement Element destination of morphing children changes + * @param newElement Element source of morphing children changes + */ +function morphChildren(currentElement, newElement, options = {}) { + morphElements(currentElement, newElement.childNodes, { + ...options, + morphStyle: "innerHTML" + }); +} + +function shouldRefreshFrameWithMorphing(currentFrame, newFrame) { + return currentFrame instanceof FrameElement && + currentFrame.shouldReloadWithMorph && (!newFrame || areFramesCompatibleForRefreshing(currentFrame, newFrame)) && + !currentFrame.closest("[data-turbo-permanent]") +} + +function areFramesCompatibleForRefreshing(currentFrame, newFrame) { + // newFrame cannot yet be an instance of FrameElement because custom + // elements don't get initialized until they're attached to the DOM, so + // test its Element#nodeName instead + return newFrame instanceof Element && newFrame.nodeName === "TURBO-FRAME" && currentFrame.id === newFrame.id && + (!newFrame.getAttribute("src") || urlsAreEqual(currentFrame.src, newFrame.getAttribute("src"))) +} + +function closestFrameReloadableWithMorphing(node) { + return node.parentElement.closest("turbo-frame[src][refresh=morph]") +} + +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, { + callbacks: { + beforeNodeMorphed: (node, newNode) => { + if ( + shouldRefreshFrameWithMorphing(node, newNode) && + closestFrameReloadableWithMorphing(node) === currentElement + ) { + node.reload(); + return false + } + return true + } + } + }); + } + + async preservingPermanentElements(callback) { + return await callback() + } +} + class ProgressBar { static animationDuration = 300 /*ms*/ @@ -2050,8 +3620,9 @@ class ProgressBar { const element = document.createElement("style"); element.type = "text/css"; element.textContent = ProgressBar.defaultCSS; - if (this.cspNonce) { - element.nonce = this.cspNonce; + const cspNonce = getCspNonce(); + if (cspNonce) { + element.nonce = cspNonce; } return element } @@ -2061,10 +3632,6 @@ class ProgressBar { element.className = "turbo-progress-bar"; return element } - - get cspNonce() { - return getMetaContent("csp-nonce") - } } class HeadSnapshot extends Snapshot { @@ -2215,6 +3782,10 @@ class PageSnapshot extends Snapshot { clonedPasswordInput.value = ""; } + for (const clonedNoscriptElement of clonedElement.querySelectorAll("noscript")) { + clonedNoscriptElement.remove(); + } + return new PageSnapshot(this.documentElement, clonedElement, this.headSnapshot) } @@ -2222,6 +3793,10 @@ class PageSnapshot extends Snapshot { return this.documentElement.getAttribute("lang") } + get dir() { + return this.documentElement.getAttribute("dir") + } + get headElement() { return this.headSnapshot.element } @@ -2248,15 +3823,16 @@ class PageSnapshot extends Snapshot { } get prefersViewTransitions() { - return this.headSnapshot.getMetaValue("view-transition") === "same-origin" + const viewTransitionEnabled = this.getSetting("view-transition") === "true" || this.headSnapshot.getMetaValue("view-transition") === "same-origin"; + return viewTransitionEnabled && !window.matchMedia("(prefers-reduced-motion: reduce)").matches } - get shouldMorphPage() { - return this.getSetting("refresh-method") === "morph" + get refreshMethod() { + return this.getSetting("refresh-method") } - get shouldPreserveScrollPosition() { - return this.getSetting("refresh-scroll") === "preserve" + get refreshScroll() { + return this.getSetting("refresh-scroll") } // Private @@ -2295,7 +3871,8 @@ const defaultOptions = { willRender: true, updateHistory: true, shouldCacheSnapshot: true, - acceptsStreamResponse: false + acceptsStreamResponse: false, + refresh: {} }; const TimingMetric = { @@ -2355,7 +3932,8 @@ class Visit { updateHistory, shouldCacheSnapshot, acceptsStreamResponse, - direction + direction, + refresh } = { ...defaultOptions, ...options @@ -2366,7 +3944,6 @@ class Visit { 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; @@ -2375,6 +3952,7 @@ class Visit { this.shouldCacheSnapshot = shouldCacheSnapshot; this.acceptsStreamResponse = acceptsStreamResponse; this.direction = direction || Direction[action]; + this.refresh = refresh; } get adapter() { @@ -2393,10 +3971,6 @@ class Visit { return this.history.getRestorationDataForIdentifier(this.restorationIdentifier) } - get silent() { - return this.isSamePage - } - start() { if (this.state == VisitState.initialized) { this.recordTimingMetric(TimingMetric.visitStart); @@ -2533,7 +4107,7 @@ class Visit { const isPreview = this.shouldIssueRequest(); this.render(async () => { this.cacheSnapshot(); - if (this.isSamePage || this.isPageRefresh) { + if (this.isPageRefresh) { this.adapter.visitRendered(this); } else { if (this.view.renderPromise) await this.view.renderPromise; @@ -2561,17 +4135,6 @@ class Visit { } } - goToSamePageAnchor() { - if (this.isSamePage) { - this.render(async () => { - this.cacheSnapshot(); - this.performScroll(); - this.changeHistory(); - this.adapter.visitRendered(this); - }); - } - } - // Fetch request delegate prepareRequest(request) { @@ -2633,9 +4196,6 @@ class Visit { } else { this.scrollToAnchor() || this.view.scrollToTop(); } - if (this.isSamePage) { - this.delegate.visitScrolledToSamePageLocation(this.view.lastRenderedLocation, this.location); - } this.scrolled = true; } @@ -2669,24 +4229,12 @@ class Visit { // 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") { + if (this.action == "restore") { return !this.hasCachedSnapshot() } else { return this.willRender @@ -2702,7 +4250,10 @@ class Visit { async render(callback) { this.cancelRender(); - this.frame = await nextRepaint(); + await new Promise((resolve) => { + this.frame = + document.visibilityState === "hidden" ? setTimeout(() => resolve(), 0) : requestAnimationFrame(() => resolve()); + }); await callback(); delete this.frame; } @@ -2743,9 +4294,10 @@ class BrowserAdapter { visitStarted(visit) { this.location = visit.location; + this.redirectedToLocation = null; + visit.loadCachedSnapshot(); visit.issueRequest(); - visit.goToSamePageAnchor(); } visitRequestStarted(visit) { @@ -2759,6 +4311,10 @@ class BrowserAdapter { visitRequestCompleted(visit) { visit.loadResponse(); + + if (visit.response.redirected) { + this.redirectedToLocation = visit.redirectedToLocation; + } } visitRequestFailedWithStatusCode(visit, statusCode) { @@ -2795,6 +4351,12 @@ class BrowserAdapter { visitRendered(_visit) {} + // Link prefetching + + linkPrefetchingIsEnabledForLocation(location) { + return true + } + // Form Submission Delegate formSubmissionStarted(_formSubmission) { @@ -2842,7 +4404,7 @@ class BrowserAdapter { reload(reason) { dispatch("turbo:reload", { detail: reason }); - window.location.href = this.location?.toString() || window.location.href; + window.location.href = (this.redirectedToLocation || this.location)?.toString() || window.location.href; } get navigator() { @@ -2852,7 +4414,6 @@ class BrowserAdapter { class CacheObserver { selector = "[data-turbo-temporary]" - deprecatedSelector = "[data-turbo-cache=false]" started = false @@ -2877,19 +4438,7 @@ class CacheObserver { } 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] + return [...document.querySelectorAll(this.selector)] } } @@ -2979,7 +4528,6 @@ class History { restorationIdentifier = uuid() restorationData = {} started = false - pageLoaded = false currentIndex = 0 constructor(delegate) { @@ -2989,7 +4537,6 @@ class History { 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)); @@ -2999,7 +4546,6 @@ class History { stop() { if (this.started) { removeEventListener("popstate", this.onPopState, false); - removeEventListener("load", this.onPageLoad, false); this.started = false; } } @@ -3055,34 +4601,20 @@ class History { // 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; - } + const { turbo } = event.state || {}; + this.location = new URL(window.location.href); + + if (turbo) { + const { restorationIdentifier, restorationIndex } = turbo; + this.restorationIdentifier = restorationIdentifier; + const direction = restorationIndex > this.currentIndex ? "forward" : "back"; + this.delegate.historyPoppedToLocationWithRestorationIdentifierAndDirection(this.location, restorationIdentifier, direction); + this.currentIndex = restorationIndex; + } else { + this.currentIndex++; + this.delegate.historyPoppedWithEmptyState(this.location); } } - - 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 { @@ -3155,7 +4687,9 @@ class LinkPrefetchObserver { target ); - prefetchCache.setLater(location.toString(), fetchRequest, this.#cacheTtl); + fetchRequest.fetchOptions.priority = "low"; + + prefetchCache.putLater(location, fetchRequest, this.#cacheTtl); } } } @@ -3171,7 +4705,7 @@ class LinkPrefetchObserver { #tryToUsePrefetchedRequest = (event) => { if (event.target.tagName !== "FORM" && event.detail.fetchOptions.method === "GET") { - const cached = prefetchCache.get(event.detail.url.toString()); + const cached = prefetchCache.get(event.detail.url); if (cached) { // User clicked link, use cache response @@ -3361,7 +4895,7 @@ class Navigator { } else { await this.view.renderPage(snapshot, false, true, this.currentVisit); } - if(!snapshot.shouldPreserveScrollPosition) { + if (snapshot.refreshScroll !== "preserve") { this.view.scrollToTop(); } this.view.clearSnapshotCache(); @@ -3379,6 +4913,17 @@ class Navigator { } } + // Link prefetching + + linkPrefetchingIsEnabledForLocation(location) { + // Not all adapters implement linkPrefetchingIsEnabledForLocation + if (typeof this.adapter.linkPrefetchingIsEnabledForLocation === "function") { + return this.adapter.linkPrefetchingIsEnabledForLocation(location) + } + + return true + } + // Visit delegate visitStarted(visit) { @@ -3390,20 +4935,10 @@ class Navigator { delete this.currentVisit; } + // Same-page links are no longer handled with a Visit. + // This method is still needed for Turbo Native adapters. 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); + return false } // Visits @@ -3737,914 +5272,6 @@ class ErrorRenderer extends Renderer { } } -// base IIFE to define idiomorph -var Idiomorph = (function () { - - //============================================================================= - // AND NOW IT BEGINS... - //============================================================================= - let EMPTY_SET = new Set(); - - // default configuration values, updatable by users now - let defaults = { - morphStyle: "outerHTML", - callbacks : { - beforeNodeAdded: noOp, - afterNodeAdded: noOp, - beforeNodeMorphed: noOp, - afterNodeMorphed: noOp, - beforeNodeRemoved: noOp, - afterNodeRemoved: noOp, - beforeAttributeUpdated: noOp, - - }, - head: { - style: 'merge', - shouldPreserve: function (elt) { - return elt.getAttribute("im-preserve") === "true"; - }, - shouldReAppend: function (elt) { - return elt.getAttribute("im-re-append") === "true"; - }, - shouldRemove: noOp, - afterHeadMorphed: noOp, - } - }; - - //============================================================================= - // Core Morphing Algorithm - morph, morphNormalizedContent, morphOldNodeTo, morphChildren - //============================================================================= - function morph(oldNode, newContent, config = {}) { - - if (oldNode instanceof Document) { - oldNode = oldNode.documentElement; - } - - if (typeof newContent === 'string') { - newContent = parseContent(newContent); - } - - let normalizedContent = normalizeContent(newContent); - - let ctx = createMorphContext(oldNode, normalizedContent, config); - - return morphNormalizedContent(oldNode, normalizedContent, ctx); - } - - function morphNormalizedContent(oldNode, normalizedNewContent, ctx) { - if (ctx.head.block) { - let oldHead = oldNode.querySelector('head'); - let newHead = normalizedNewContent.querySelector('head'); - if (oldHead && newHead) { - let promises = handleHeadElement(newHead, oldHead, ctx); - // when head promises resolve, call morph again, ignoring the head tag - Promise.all(promises).then(function () { - morphNormalizedContent(oldNode, normalizedNewContent, Object.assign(ctx, { - head: { - block: false, - ignore: true - } - })); - }); - return; - } - } - - if (ctx.morphStyle === "innerHTML") { - - // innerHTML, so we are only updating the children - morphChildren(normalizedNewContent, oldNode, ctx); - return oldNode.children; - - } else if (ctx.morphStyle === "outerHTML" || ctx.morphStyle == null) { - // otherwise find the best element match in the new content, morph that, and merge its siblings - // into either side of the best match - let bestMatch = findBestNodeMatch(normalizedNewContent, oldNode, ctx); - - // stash the siblings that will need to be inserted on either side of the best match - let previousSibling = bestMatch?.previousSibling; - let nextSibling = bestMatch?.nextSibling; - - // morph it - let morphedNode = morphOldNodeTo(oldNode, bestMatch, ctx); - - if (bestMatch) { - // if there was a best match, merge the siblings in too and return the - // whole bunch - return insertSiblings(previousSibling, morphedNode, nextSibling); - } else { - // otherwise nothing was added to the DOM - return [] - } - } else { - throw "Do not understand how to morph style " + ctx.morphStyle; - } - } - - - /** - * @param possibleActiveElement - * @param ctx - * @returns {boolean} - */ - function ignoreValueOfActiveElement(possibleActiveElement, ctx) { - return ctx.ignoreActiveValue && possibleActiveElement === document.activeElement && possibleActiveElement !== document.body; - } - - /** - * @param oldNode root node to merge content into - * @param newContent new content to merge - * @param ctx the merge context - * @returns {Element} the element that ended up in the DOM - */ - function morphOldNodeTo(oldNode, newContent, ctx) { - if (ctx.ignoreActive && oldNode === document.activeElement) ; else if (newContent == null) { - if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode; - - oldNode.remove(); - ctx.callbacks.afterNodeRemoved(oldNode); - return null; - } else if (!isSoftMatch(oldNode, newContent)) { - if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode; - if (ctx.callbacks.beforeNodeAdded(newContent) === false) return oldNode; - - oldNode.parentElement.replaceChild(newContent, oldNode); - ctx.callbacks.afterNodeAdded(newContent); - ctx.callbacks.afterNodeRemoved(oldNode); - return newContent; - } else { - if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) return oldNode; - - if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) ; else if (oldNode instanceof HTMLHeadElement && ctx.head.style !== "morph") { - handleHeadElement(newContent, oldNode, ctx); - } else { - syncNodeFrom(newContent, oldNode, ctx); - if (!ignoreValueOfActiveElement(oldNode, ctx)) { - morphChildren(newContent, oldNode, ctx); - } - } - ctx.callbacks.afterNodeMorphed(oldNode, newContent); - return oldNode; - } - } - - /** - * This is the core algorithm for matching up children. The idea is to use id sets to try to match up - * nodes as faithfully as possible. We greedily match, which allows us to keep the algorithm fast, but - * by using id sets, we are able to better match up with content deeper in the DOM. - * - * Basic algorithm is, for each node in the new content: - * - * - if we have reached the end of the old parent, append the new content - * - if the new content has an id set match with the current insertion point, morph - * - search for an id set match - * - if id set match found, morph - * - otherwise search for a "soft" match - * - if a soft match is found, morph - * - otherwise, prepend the new node before the current insertion point - * - * The two search algorithms terminate if competing node matches appear to outweigh what can be achieved - * with the current node. See findIdSetMatch() and findSoftMatch() for details. - * - * @param {Element} newParent the parent element of the new content - * @param {Element } oldParent the old content that we are merging the new content into - * @param ctx the merge context - */ - function morphChildren(newParent, oldParent, ctx) { - - let nextNewChild = newParent.firstChild; - let insertionPoint = oldParent.firstChild; - let newChild; - - // run through all the new content - while (nextNewChild) { - - newChild = nextNewChild; - nextNewChild = newChild.nextSibling; - - // if we are at the end of the exiting parent's children, just append - if (insertionPoint == null) { - if (ctx.callbacks.beforeNodeAdded(newChild) === false) return; - - oldParent.appendChild(newChild); - ctx.callbacks.afterNodeAdded(newChild); - removeIdsFromConsideration(ctx, newChild); - continue; - } - - // if the current node has an id set match then morph - if (isIdSetMatch(newChild, insertionPoint, ctx)) { - morphOldNodeTo(insertionPoint, newChild, ctx); - insertionPoint = insertionPoint.nextSibling; - removeIdsFromConsideration(ctx, newChild); - continue; - } - - // otherwise search forward in the existing old children for an id set match - let idSetMatch = findIdSetMatch(newParent, oldParent, newChild, insertionPoint, ctx); - - // if we found a potential match, remove the nodes until that point and morph - if (idSetMatch) { - insertionPoint = removeNodesBetween(insertionPoint, idSetMatch, ctx); - morphOldNodeTo(idSetMatch, newChild, ctx); - removeIdsFromConsideration(ctx, newChild); - continue; - } - - // no id set match found, so scan forward for a soft match for the current node - let softMatch = findSoftMatch(newParent, oldParent, newChild, insertionPoint, ctx); - - // if we found a soft match for the current node, morph - if (softMatch) { - insertionPoint = removeNodesBetween(insertionPoint, softMatch, ctx); - morphOldNodeTo(softMatch, newChild, ctx); - removeIdsFromConsideration(ctx, newChild); - continue; - } - - // abandon all hope of morphing, just insert the new child before the insertion point - // and move on - if (ctx.callbacks.beforeNodeAdded(newChild) === false) return; - - oldParent.insertBefore(newChild, insertionPoint); - ctx.callbacks.afterNodeAdded(newChild); - removeIdsFromConsideration(ctx, newChild); - } - - // remove any remaining old nodes that didn't match up with new content - while (insertionPoint !== null) { - - let tempNode = insertionPoint; - insertionPoint = insertionPoint.nextSibling; - removeNode(tempNode, ctx); - } - } - - //============================================================================= - // Attribute Syncing Code - //============================================================================= - - /** - * @param attr {String} the attribute to be mutated - * @param to {Element} the element that is going to be updated - * @param updateType {("update"|"remove")} - * @param ctx the merge context - * @returns {boolean} true if the attribute should be ignored, false otherwise - */ - function ignoreAttribute(attr, to, updateType, ctx) { - if(attr === 'value' && ctx.ignoreActiveValue && to === document.activeElement){ - return true; - } - return ctx.callbacks.beforeAttributeUpdated(attr, to, updateType) === false; - } - - /** - * syncs a given node with another node, copying over all attributes and - * inner element state from the 'from' node to the 'to' node - * - * @param {Element} from the element to copy attributes & state from - * @param {Element} to the element to copy attributes & state to - * @param ctx the merge context - */ - function syncNodeFrom(from, to, ctx) { - let type = from.nodeType; - - // if is an element type, sync the attributes from the - // new node into the new node - if (type === 1 /* element type */) { - const fromAttributes = from.attributes; - const toAttributes = to.attributes; - for (const fromAttribute of fromAttributes) { - if (ignoreAttribute(fromAttribute.name, to, 'update', ctx)) { - continue; - } - if (to.getAttribute(fromAttribute.name) !== fromAttribute.value) { - to.setAttribute(fromAttribute.name, fromAttribute.value); - } - } - // iterate backwards to avoid skipping over items when a delete occurs - for (let i = toAttributes.length - 1; 0 <= i; i--) { - const toAttribute = toAttributes[i]; - if (ignoreAttribute(toAttribute.name, to, 'remove', ctx)) { - continue; - } - if (!from.hasAttribute(toAttribute.name)) { - to.removeAttribute(toAttribute.name); - } - } - } - - // sync text nodes - if (type === 8 /* comment */ || type === 3 /* text */) { - if (to.nodeValue !== from.nodeValue) { - to.nodeValue = from.nodeValue; - } - } - - if (!ignoreValueOfActiveElement(to, ctx)) { - // sync input values - syncInputValue(from, to, ctx); - } - } - - /** - * @param from {Element} element to sync the value from - * @param to {Element} element to sync the value to - * @param attributeName {String} the attribute name - * @param ctx the merge context - */ - function syncBooleanAttribute(from, to, attributeName, ctx) { - if (from[attributeName] !== to[attributeName]) { - let ignoreUpdate = ignoreAttribute(attributeName, to, 'update', ctx); - if (!ignoreUpdate) { - to[attributeName] = from[attributeName]; - } - if (from[attributeName]) { - if (!ignoreUpdate) { - to.setAttribute(attributeName, from[attributeName]); - } - } else { - if (!ignoreAttribute(attributeName, to, 'remove', ctx)) { - to.removeAttribute(attributeName); - } - } - } - } - - /** - * NB: many bothans died to bring us information: - * - * https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js - * https://github.com/choojs/nanomorph/blob/master/lib/morph.jsL113 - * - * @param from {Element} the element to sync the input value from - * @param to {Element} the element to sync the input value to - * @param ctx the merge context - */ - function syncInputValue(from, to, ctx) { - if (from instanceof HTMLInputElement && - to instanceof HTMLInputElement && - from.type !== 'file') { - - let fromValue = from.value; - let toValue = to.value; - - // sync boolean attributes - syncBooleanAttribute(from, to, 'checked', ctx); - syncBooleanAttribute(from, to, 'disabled', ctx); - - if (!from.hasAttribute('value')) { - if (!ignoreAttribute('value', to, 'remove', ctx)) { - to.value = ''; - to.removeAttribute('value'); - } - } else if (fromValue !== toValue) { - if (!ignoreAttribute('value', to, 'update', ctx)) { - to.setAttribute('value', fromValue); - to.value = fromValue; - } - } - } else if (from instanceof HTMLOptionElement) { - syncBooleanAttribute(from, to, 'selected', ctx); - } else if (from instanceof HTMLTextAreaElement && to instanceof HTMLTextAreaElement) { - let fromValue = from.value; - let toValue = to.value; - if (ignoreAttribute('value', to, 'update', ctx)) { - return; - } - if (fromValue !== toValue) { - to.value = fromValue; - } - if (to.firstChild && to.firstChild.nodeValue !== fromValue) { - to.firstChild.nodeValue = fromValue; - } - } - } - - //============================================================================= - // the HEAD tag can be handled specially, either w/ a 'merge' or 'append' style - //============================================================================= - function handleHeadElement(newHeadTag, currentHead, ctx) { - - let added = []; - let removed = []; - let preserved = []; - let nodesToAppend = []; - - let headMergeStyle = ctx.head.style; - - // put all new head elements into a Map, by their outerHTML - let srcToNewHeadNodes = new Map(); - for (const newHeadChild of newHeadTag.children) { - srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild); - } - - // for each elt in the current head - for (const currentHeadElt of currentHead.children) { - - // If the current head element is in the map - let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML); - let isReAppended = ctx.head.shouldReAppend(currentHeadElt); - let isPreserved = ctx.head.shouldPreserve(currentHeadElt); - if (inNewContent || isPreserved) { - if (isReAppended) { - // remove the current version and let the new version replace it and re-execute - removed.push(currentHeadElt); - } else { - // this element already exists and should not be re-appended, so remove it from - // the new content map, preserving it in the DOM - srcToNewHeadNodes.delete(currentHeadElt.outerHTML); - preserved.push(currentHeadElt); - } - } else { - if (headMergeStyle === "append") { - // we are appending and this existing element is not new content - // so if and only if it is marked for re-append do we do anything - if (isReAppended) { - removed.push(currentHeadElt); - nodesToAppend.push(currentHeadElt); - } - } else { - // if this is a merge, we remove this content since it is not in the new head - if (ctx.head.shouldRemove(currentHeadElt) !== false) { - removed.push(currentHeadElt); - } - } - } - } - - // Push the remaining new head elements in the Map into the - // nodes to append to the head tag - nodesToAppend.push(...srcToNewHeadNodes.values()); - - let promises = []; - for (const newNode of nodesToAppend) { - let newElt = document.createRange().createContextualFragment(newNode.outerHTML).firstChild; - if (ctx.callbacks.beforeNodeAdded(newElt) !== false) { - if (newElt.href || newElt.src) { - let resolve = null; - let promise = new Promise(function (_resolve) { - resolve = _resolve; - }); - newElt.addEventListener('load', function () { - resolve(); - }); - promises.push(promise); - } - currentHead.appendChild(newElt); - ctx.callbacks.afterNodeAdded(newElt); - added.push(newElt); - } - } - - // remove all removed elements, after we have appended the new elements to avoid - // additional network requests for things like style sheets - for (const removedElement of removed) { - if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) { - currentHead.removeChild(removedElement); - ctx.callbacks.afterNodeRemoved(removedElement); - } - } - - ctx.head.afterHeadMorphed(currentHead, {added: added, kept: preserved, removed: removed}); - return promises; - } - - function noOp() { - } - - /* - Deep merges the config object and the Idiomoroph.defaults object to - produce a final configuration object - */ - function mergeDefaults(config) { - let finalConfig = {}; - // copy top level stuff into final config - Object.assign(finalConfig, defaults); - Object.assign(finalConfig, config); - - // copy callbacks into final config (do this to deep merge the callbacks) - finalConfig.callbacks = {}; - Object.assign(finalConfig.callbacks, defaults.callbacks); - Object.assign(finalConfig.callbacks, config.callbacks); - - // copy head config into final config (do this to deep merge the head) - finalConfig.head = {}; - Object.assign(finalConfig.head, defaults.head); - Object.assign(finalConfig.head, config.head); - return finalConfig; - } - - function createMorphContext(oldNode, newContent, config) { - config = mergeDefaults(config); - return { - target: oldNode, - newContent: newContent, - config: config, - morphStyle: config.morphStyle, - ignoreActive: config.ignoreActive, - ignoreActiveValue: config.ignoreActiveValue, - idMap: createIdMap(oldNode, newContent), - deadIds: new Set(), - callbacks: config.callbacks, - head: config.head - } - } - - function isIdSetMatch(node1, node2, ctx) { - if (node1 == null || node2 == null) { - return false; - } - if (node1.nodeType === node2.nodeType && node1.tagName === node2.tagName) { - if (node1.id !== "" && node1.id === node2.id) { - return true; - } else { - return getIdIntersectionCount(ctx, node1, node2) > 0; - } - } - return false; - } - - function isSoftMatch(node1, node2) { - if (node1 == null || node2 == null) { - return false; - } - return node1.nodeType === node2.nodeType && node1.tagName === node2.tagName - } - - function removeNodesBetween(startInclusive, endExclusive, ctx) { - while (startInclusive !== endExclusive) { - let tempNode = startInclusive; - startInclusive = startInclusive.nextSibling; - removeNode(tempNode, ctx); - } - removeIdsFromConsideration(ctx, endExclusive); - return endExclusive.nextSibling; - } - - //============================================================================= - // Scans forward from the insertionPoint in the old parent looking for a potential id match - // for the newChild. We stop if we find a potential id match for the new child OR - // if the number of potential id matches we are discarding is greater than the - // potential id matches for the new child - //============================================================================= - function findIdSetMatch(newContent, oldParent, newChild, insertionPoint, ctx) { - - // max id matches we are willing to discard in our search - let newChildPotentialIdCount = getIdIntersectionCount(ctx, newChild, oldParent); - - let potentialMatch = null; - - // only search forward if there is a possibility of an id match - if (newChildPotentialIdCount > 0) { - let potentialMatch = insertionPoint; - // if there is a possibility of an id match, scan forward - // keep track of the potential id match count we are discarding (the - // newChildPotentialIdCount must be greater than this to make it likely - // worth it) - let otherMatchCount = 0; - while (potentialMatch != null) { - - // If we have an id match, return the current potential match - if (isIdSetMatch(newChild, potentialMatch, ctx)) { - return potentialMatch; - } - - // computer the other potential matches of this new content - otherMatchCount += getIdIntersectionCount(ctx, potentialMatch, newContent); - if (otherMatchCount > newChildPotentialIdCount) { - // if we have more potential id matches in _other_ content, we - // do not have a good candidate for an id match, so return null - return null; - } - - // advanced to the next old content child - potentialMatch = potentialMatch.nextSibling; - } - } - return potentialMatch; - } - - //============================================================================= - // Scans forward from the insertionPoint in the old parent looking for a potential soft match - // for the newChild. We stop if we find a potential soft match for the new child OR - // if we find a potential id match in the old parents children OR if we find two - // potential soft matches for the next two pieces of new content - //============================================================================= - function findSoftMatch(newContent, oldParent, newChild, insertionPoint, ctx) { - - let potentialSoftMatch = insertionPoint; - let nextSibling = newChild.nextSibling; - let siblingSoftMatchCount = 0; - - while (potentialSoftMatch != null) { - - if (getIdIntersectionCount(ctx, potentialSoftMatch, newContent) > 0) { - // the current potential soft match has a potential id set match with the remaining new - // content so bail out of looking - return null; - } - - // if we have a soft match with the current node, return it - if (isSoftMatch(newChild, potentialSoftMatch)) { - return potentialSoftMatch; - } - - if (isSoftMatch(nextSibling, potentialSoftMatch)) { - // the next new node has a soft match with this node, so - // increment the count of future soft matches - siblingSoftMatchCount++; - nextSibling = nextSibling.nextSibling; - - // If there are two future soft matches, bail to allow the siblings to soft match - // so that we don't consume future soft matches for the sake of the current node - if (siblingSoftMatchCount >= 2) { - return null; - } - } - - // advanced to the next old content child - potentialSoftMatch = potentialSoftMatch.nextSibling; - } - - return potentialSoftMatch; - } - - function parseContent(newContent) { - let parser = new DOMParser(); - - // remove svgs to avoid false-positive matches on head, etc. - let contentWithSvgsRemoved = newContent.replace(/]*>|>)([\s\S]*?)<\/svg>/gim, ''); - - // if the newContent contains a html, head or body tag, we can simply parse it w/o wrapping - if (contentWithSvgsRemoved.match(/<\/html>/) || contentWithSvgsRemoved.match(/<\/head>/) || contentWithSvgsRemoved.match(/<\/body>/)) { - let content = parser.parseFromString(newContent, "text/html"); - // if it is a full HTML document, return the document itself as the parent container - if (contentWithSvgsRemoved.match(/<\/html>/)) { - content.generatedByIdiomorph = true; - return content; - } else { - // otherwise return the html element as the parent container - let htmlElement = content.firstChild; - if (htmlElement) { - htmlElement.generatedByIdiomorph = true; - return htmlElement; - } else { - return null; - } - } - } else { - // if it is partial HTML, wrap it in a template tag to provide a parent element and also to help - // deal with touchy tags like tr, tbody, etc. - let responseDoc = parser.parseFromString("", "text/html"); - let content = responseDoc.body.querySelector('template').content; - content.generatedByIdiomorph = true; - return content - } - } - - function normalizeContent(newContent) { - if (newContent == null) { - // noinspection UnnecessaryLocalVariableJS - const dummyParent = document.createElement('div'); - return dummyParent; - } else if (newContent.generatedByIdiomorph) { - // the template tag created by idiomorph parsing can serve as a dummy parent - return newContent; - } else if (newContent instanceof Node) { - // a single node is added as a child to a dummy parent - const dummyParent = document.createElement('div'); - dummyParent.append(newContent); - return dummyParent; - } else { - // all nodes in the array or HTMLElement collection are consolidated under - // a single dummy parent element - const dummyParent = document.createElement('div'); - for (const elt of [...newContent]) { - dummyParent.append(elt); - } - return dummyParent; - } - } - - function insertSiblings(previousSibling, morphedNode, nextSibling) { - let stack = []; - let added = []; - while (previousSibling != null) { - stack.push(previousSibling); - previousSibling = previousSibling.previousSibling; - } - while (stack.length > 0) { - let node = stack.pop(); - added.push(node); // push added preceding siblings on in order and insert - morphedNode.parentElement.insertBefore(node, morphedNode); - } - added.push(morphedNode); - while (nextSibling != null) { - stack.push(nextSibling); - added.push(nextSibling); // here we are going in order, so push on as we scan, rather than add - nextSibling = nextSibling.nextSibling; - } - while (stack.length > 0) { - morphedNode.parentElement.insertBefore(stack.pop(), morphedNode.nextSibling); - } - return added; - } - - function findBestNodeMatch(newContent, oldNode, ctx) { - let currentElement; - currentElement = newContent.firstChild; - let bestElement = currentElement; - let score = 0; - while (currentElement) { - let newScore = scoreElement(currentElement, oldNode, ctx); - if (newScore > score) { - bestElement = currentElement; - score = newScore; - } - currentElement = currentElement.nextSibling; - } - return bestElement; - } - - function scoreElement(node1, node2, ctx) { - if (isSoftMatch(node1, node2)) { - return .5 + getIdIntersectionCount(ctx, node1, node2); - } - return 0; - } - - function removeNode(tempNode, ctx) { - removeIdsFromConsideration(ctx, tempNode); - if (ctx.callbacks.beforeNodeRemoved(tempNode) === false) return; - - tempNode.remove(); - ctx.callbacks.afterNodeRemoved(tempNode); - } - - //============================================================================= - // ID Set Functions - //============================================================================= - - function isIdInConsideration(ctx, id) { - return !ctx.deadIds.has(id); - } - - function idIsWithinNode(ctx, id, targetNode) { - let idSet = ctx.idMap.get(targetNode) || EMPTY_SET; - return idSet.has(id); - } - - function removeIdsFromConsideration(ctx, node) { - let idSet = ctx.idMap.get(node) || EMPTY_SET; - for (const id of idSet) { - ctx.deadIds.add(id); - } - } - - function getIdIntersectionCount(ctx, node1, node2) { - let sourceSet = ctx.idMap.get(node1) || EMPTY_SET; - let matchCount = 0; - for (const id of sourceSet) { - // a potential match is an id in the source and potentialIdsSet, but - // that has not already been merged into the DOM - if (isIdInConsideration(ctx, id) && idIsWithinNode(ctx, id, node2)) { - ++matchCount; - } - } - return matchCount; - } - - /** - * A bottom up algorithm that finds all elements with ids inside of the node - * argument and populates id sets for those nodes and all their parents, generating - * a set of ids contained within all nodes for the entire hierarchy in the DOM - * - * @param node {Element} - * @param {Map>} idMap - */ - function populateIdMapForNode(node, idMap) { - let nodeParent = node.parentElement; - // find all elements with an id property - let idElements = node.querySelectorAll('[id]'); - for (const elt of idElements) { - let current = elt; - // walk up the parent hierarchy of that element, adding the id - // of element to the parent's id set - while (current !== nodeParent && current != null) { - let idSet = idMap.get(current); - // if the id set doesn't exist, create it and insert it in the map - if (idSet == null) { - idSet = new Set(); - idMap.set(current, idSet); - } - idSet.add(elt.id); - current = current.parentElement; - } - } - } - - /** - * This function computes a map of nodes to all ids contained within that node (inclusive of the - * node). This map can be used to ask if two nodes have intersecting sets of ids, which allows - * for a looser definition of "matching" than tradition id matching, and allows child nodes - * to contribute to a parent nodes matching. - * - * @param {Element} oldContent the old content that will be morphed - * @param {Element} newContent the new content to morph to - * @returns {Map>} a map of nodes to id sets for the - */ - function createIdMap(oldContent, newContent) { - let idMap = new Map(); - populateIdMapForNode(oldContent, idMap); - populateIdMapForNode(newContent, idMap); - return idMap; - } - - //============================================================================= - // This is what ends up becoming the Idiomorph global object - //============================================================================= - return { - morph, - defaults - } - })(); - -function morphElements(currentElement, newElement, { callbacks, ...options } = {}) { - Idiomorph.morph(currentElement, newElement, { - ...options, - callbacks: new DefaultIdiomorphCallbacks(callbacks) - }); -} - -function morphChildren(currentElement, newElement) { - morphElements(currentElement, newElement.children, { - morphStyle: "innerHTML" - }); -} - -class DefaultIdiomorphCallbacks { - #beforeNodeMorphed - - constructor({ beforeNodeMorphed } = {}) { - this.#beforeNodeMorphed = beforeNodeMorphed || (() => true); - } - - beforeNodeAdded = (node) => { - return !(node.id && node.hasAttribute("data-turbo-permanent") && document.getElementById(node.id)) - } - - beforeNodeMorphed = (currentElement, newElement) => { - if (currentElement instanceof Element) { - if (!currentElement.hasAttribute("data-turbo-permanent") && this.#beforeNodeMorphed(currentElement, newElement)) { - const event = dispatch("turbo:before-morph-element", { - cancelable: true, - target: currentElement, - detail: { currentElement, newElement } - }); - - return !event.defaultPrevented - } else { - return false - } - } - } - - beforeAttributeUpdated = (attributeName, target, mutationType) => { - const event = dispatch("turbo:before-morph-attribute", { - cancelable: true, - target, - detail: { attributeName, mutationType } - }); - - return !event.defaultPrevented - } - - beforeNodeRemoved = (node) => { - return this.beforeNodeMorphed(node) - } - - afterNodeMorphed = (currentElement, newElement) => { - if (currentElement instanceof Element) { - dispatch("turbo:morph-element", { - target: currentElement, - detail: { currentElement, newElement } - }); - } - } -} - -class MorphingFrameRenderer extends FrameRenderer { - static renderElement(currentElement, newElement) { - dispatch("turbo:before-frame-morph", { - target: currentElement, - detail: { currentElement, newElement } - }); - - morphChildren(currentElement, newElement); - } -} - class PageRenderer extends Renderer { static renderElement(currentElement, newElement) { if (document.body && newElement instanceof HTMLBodyElement) { @@ -4704,13 +5331,18 @@ class PageRenderer extends Renderer { #setLanguage() { const { documentElement } = this.currentSnapshot; - const { lang } = this.newSnapshot; + const { dir, lang } = this.newSnapshot; if (lang) { documentElement.setAttribute("lang", lang); } else { documentElement.removeAttribute("lang"); } + if (dir) { + documentElement.setAttribute("dir", dir); + } else { + documentElement.removeAttribute("dir"); + } } async mergeHead() { @@ -4812,9 +5444,16 @@ class PageRenderer extends Renderer { activateNewBody() { document.adoptNode(this.newElement); + this.removeNoscriptElements(); this.activateNewBodyScriptElements(); } + removeNoscriptElements() { + for (const noscriptElement of this.newElement.querySelectorAll("noscript")) { + noscriptElement.remove(); + } + } + activateNewBodyScriptElements() { for (const inertScriptElement of this.newBodyScriptElements) { const activatedScriptElement = activateScriptElement(inertScriptElement); @@ -4861,14 +5500,19 @@ class MorphingPageRenderer extends PageRenderer { static renderElement(currentElement, newElement) { morphElements(currentElement, newElement, { callbacks: { - beforeNodeMorphed: element => !canRefreshFrame(element) + beforeNodeMorphed: (node, newNode) => { + if ( + shouldRefreshFrameWithMorphing(node, newNode) && + !closestFrameReloadableWithMorphing(node) + ) { + node.reload(); + return false + } + return true + } } }); - for (const frame of currentElement.querySelectorAll("turbo-frame")) { - if (canRefreshFrame(frame)) refreshFrame(frame); - } - dispatch("turbo:morph", { detail: { currentElement, newElement } }); } @@ -4885,73 +5529,13 @@ class MorphingPageRenderer extends PageRenderer { } } -function canRefreshFrame(frame) { - return frame instanceof FrameElement && - frame.src && - frame.refresh === "morph" && - !frame.closest("[data-turbo-permanent]") -} - -function refreshFrame(frame) { - frame.addEventListener("turbo:before-frame-render", ({ detail }) => { - detail.render = MorphingFrameRenderer.renderElement; - }, { once: true }); - - frame.reload(); -} - -class SnapshotCache { - keys = [] - snapshots = {} - +class SnapshotCache extends LRUCache { constructor(size) { - this.size = size; + super(size, toCacheKey); } - 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]; - } + get snapshots() { + return this.entries } } @@ -4965,10 +5549,10 @@ class PageView extends View { } renderPage(snapshot, isPreview = false, willRender = true, visit) { - const shouldMorphPage = this.isPageRefresh(visit) && this.snapshot.shouldMorphPage; + const shouldMorphPage = this.isPageRefresh(visit) && (visit?.refresh?.method || this.snapshot.refreshMethod) === "morph"; const rendererClass = shouldMorphPage ? MorphingPageRenderer : PageRenderer; - const renderer = new rendererClass(this.snapshot, snapshot, rendererClass.renderElement, isPreview, willRender); + const renderer = new rendererClass(this.snapshot, snapshot, isPreview, willRender); if (!renderer.shouldRender) { this.forceReloaded = true; @@ -4981,7 +5565,7 @@ class PageView extends View { renderError(snapshot, visit) { visit?.changeHistory(); - const renderer = new ErrorRenderer(this.snapshot, snapshot, ErrorRenderer.renderElement, false); + const renderer = new ErrorRenderer(this.snapshot, snapshot, false); return this.render(renderer) } @@ -5009,7 +5593,7 @@ class PageView extends View { } shouldPreserveScrollPosition(visit) { - return this.isPageRefresh(visit) && this.snapshot.shouldPreserveScrollPosition + return this.isPageRefresh(visit) && (visit?.refresh?.scroll || this.snapshot.refreshScroll) === "preserve" } get snapshot() { @@ -5132,11 +5716,8 @@ class Session { streamMessageRenderer = new StreamMessageRenderer() cache = new Cache(this) - drive = true enabled = true - progressBarDelay = 500 started = false - formMode = "on" #pageRefreshDebouncePeriod = 150 constructor(recentRequests) { @@ -5202,10 +5783,14 @@ class Session { } } - refresh(url, requestId) { + refresh(url, options = {}) { + options = typeof options === "string" ? { requestId: options } : options; + + const { method, requestId, scroll } = options; const isRecentRequest = requestId && this.recentRequests.has(requestId); - if (!isRecentRequest && !this.navigator.currentVisit) { - this.visit(url, { action: "replace", shouldCacheSnapshot: false }); + const isCurrentUrl = url === document.baseURI; + if (!isRecentRequest && !this.navigator.currentVisit && isCurrentUrl) { + this.visit(url, { action: "replace", shouldCacheSnapshot: false, refresh: { method, scroll } }); } } @@ -5226,11 +5811,35 @@ class Session { } 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; } - setFormMode(mode) { - this.formMode = mode; + 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() { @@ -5285,6 +5894,12 @@ class Session { } } + historyPoppedWithEmptyState(location) { + this.history.replace(location); + this.view.lastRenderedLocation = location; + this.view.cacheSnapshot(); + } + // Scroll observer delegate scrollPositionChanged(position) { @@ -5304,7 +5919,8 @@ class Session { canPrefetchRequestToLocation(link, location) { return ( this.elementIsNavigatable(link) && - locationIsVisitable(location, this.snapshot.rootLocation) + locationIsVisitable(location, this.snapshot.rootLocation) && + this.navigator.linkPrefetchingIsEnabledForLocation(location) ) } @@ -5328,7 +5944,7 @@ class Session { // Navigator delegate allowsVisitingLocationWithAction(location, action) { - return this.locationWithActionIsSamePage(location, action) || this.applicationAllowsVisitingLocation(location) + return this.applicationAllowsVisitingLocation(location) } visitProposedToLocation(location, options) { @@ -5344,9 +5960,7 @@ class Session { this.view.markVisitDirection(visit.direction); } extendURLWithDeprecatedProperties(visit.location); - if (!visit.silent) { - this.notifyApplicationAfterVisitingLocation(visit.location, visit.action); - } + this.notifyApplicationAfterVisitingLocation(visit.location, visit.action); } visitCompleted(visit) { @@ -5355,14 +5969,6 @@ class Session { 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) { @@ -5402,9 +6008,7 @@ class Session { // Page view delegate viewWillCacheSnapshot() { - if (!this.navigator.currentVisit?.silent) { - this.notifyApplicationBeforeCachingSnapshot(); - } + this.notifyApplicationBeforeCachingSnapshot(); } allowsImmediateRender({ element }, options) { @@ -5496,15 +6100,6 @@ class Session { }) } - notifyApplicationAfterVisitingSamePageLocation(oldURL, newURL) { - dispatchEvent( - new HashChangeEvent("hashchange", { - oldURL: oldURL.toString(), - newURL: newURL.toString() - }) - ); - } - notifyApplicationAfterFrameLoad(frame) { return dispatch("turbo:frame-load", { target: frame }) } @@ -5520,12 +6115,12 @@ class Session { // Helpers submissionIsNavigatable(form, submitter) { - if (this.formMode == "off") { + if (config.forms.mode == "off") { return false } else { const submitterIsNavigatable = submitter ? this.elementIsNavigatable(submitter) : true; - if (this.formMode == "optin") { + if (config.forms.mode == "optin") { return submitterIsNavigatable && form.closest('[data-turbo="true"]') != null } else { return submitterIsNavigatable && this.elementIsNavigatable(form) @@ -5538,7 +6133,7 @@ class Session { const withinFrame = findClosestRecursively(element, "turbo-frame"); // Check if Drive is enabled on the session or we're within a Frame. - if (this.drive || withinFrame) { + if (config.drive.enabled || withinFrame) { // Element is navigatable by default, unless `data-turbo="false"`. if (container) { return container.getAttribute("data-turbo") != "false" @@ -5590,7 +6185,9 @@ const deprecatedLocationPropertyDescriptors = { }; const session = new Session(recentRequests); -const { cache, navigator: navigator$1 } = session; + +// Rename `navigator` to avoid shadowing `window.navigator` +const { cache, navigator: sessionNavigator } = session; /** * Starts the main session. @@ -5656,19 +6253,6 @@ 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. * @@ -5680,36 +6264,75 @@ function clearCache() { * @param delay Time to delay in milliseconds */ function setProgressBarDelay(delay) { - session.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) { - FormSubmission.confirmMethod = 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) { - session.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; +} + +/** + * Morph the state of the currentBody based on the attributes and contents of + * the newBody. Morphing body elements may dispatch turbo:morph, + * turbo:before-morph-element, turbo:before-morph-attribute, and + * turbo:morph-element events. + * + * @param currentBody HTMLBodyElement destination of morphing changes + * @param newBody HTMLBodyElement source of morphing changes + */ +function morphBodyElements(currentBody, newBody) { + MorphingPageRenderer.renderElement(currentBody, newBody); +} + +/** + * Morph the child elements of the currentFrame based on the child elements of + * the newFrame. Morphing turbo-frame elements may dispatch turbo:before-frame-morph, + * turbo:before-morph-element, turbo:before-morph-attribute, and + * turbo:morph-element events. + * + * @param currentFrame FrameElement destination of morphing children changes + * @param newFrame FrameElement source of morphing children changes + */ +function morphTurboFrameElements(currentFrame, newFrame) { + MorphingFrameRenderer.renderElement(currentFrame, newFrame); } var Turbo = /*#__PURE__*/Object.freeze({ __proto__: null, - navigator: navigator$1, - session: session, - cache: cache, PageRenderer: PageRenderer, PageSnapshot: PageSnapshot, FrameRenderer: FrameRenderer, fetch: fetchWithTurboHeaders, + config: config, + session: session, + cache: cache, + navigator: sessionNavigator, start: start, registerAdapter: registerAdapter, visit: visit, connectStreamSource: connectStreamSource, disconnectStreamSource: disconnectStreamSource, renderStreamMessage: renderStreamMessage, - clearCache: clearCache, setProgressBarDelay: setProgressBarDelay, setConfirmMethod: setConfirmMethod, - setFormMode: setFormMode + setFormMode: setFormMode, + morphBodyElements: morphBodyElements, + morphTurboFrameElements: morphTurboFrameElements, + morphChildren: morphChildren, + morphElements: morphElements }); class TurboFrameMissingError extends Error {} @@ -5721,6 +6344,7 @@ class FrameController { #connected = false #hasBeenLoaded = false #ignoredAttributes = new Set() + #shouldMorphFrame = false action = null constructor(element) { @@ -5756,11 +6380,17 @@ class FrameController { this.formLinkClickObserver.stop(); this.linkInterceptor.stop(); this.formSubmitObserver.stop(); + + if (!this.element.hasAttribute("recurse")) { + this.#currentFetchRequest?.cancel(); + } } } disabledChanged() { - if (this.loadingStyle == FrameLoadingStyle.eager) { + if (this.disabled) { + this.#currentFetchRequest?.cancel(); + } else if (this.loadingStyle == FrameLoadingStyle.eager) { this.#loadSourceURL(); } } @@ -5768,6 +6398,10 @@ class FrameController { sourceURLChanged() { if (this.#isIgnoringChangesTo("src")) return + if (!this.sourceURL) { + this.#currentFetchRequest?.cancel(); + } + if (this.element.isConnected) { this.complete = false; } @@ -5778,7 +6412,10 @@ class FrameController { } sourceURLReloaded() { - const { src } = this.element; + const { refresh, src } = this.element; + + this.#shouldMorphFrame = src && refresh === "morph"; + this.element.removeAttribute("complete"); this.element.src = null; this.element.src = src; @@ -5821,6 +6458,7 @@ class FrameController { } } } finally { + this.#shouldMorphFrame = false; this.fetchResponseLoaded = () => Promise.resolve(); } } @@ -5865,15 +6503,18 @@ class FrameController { } this.formSubmission = new FormSubmission(this, element, submitter); + const { fetchRequest } = this.formSubmission; - this.prepareRequest(fetchRequest); + const frame = this.#findFrameElement(element, submitter); + + this.prepareRequest(fetchRequest, frame); this.formSubmission.start(); } // Fetch request delegate - prepareRequest(request) { - request.headers["Turbo-Frame"] = this.id; + prepareRequest(request, frame = this) { + request.headers["Turbo-Frame"] = frame.id; if (this.currentNavigationElement?.hasAttribute("data-turbo-stream")) { request.acceptResponseType(StreamMessage.contentType); @@ -5945,6 +6586,7 @@ class FrameController { detail: { newFrame, ...options }, cancelable: true }); + const { defaultPrevented, detail: { render } @@ -5985,10 +6627,11 @@ class FrameController { 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 FrameRenderer(this, this.view.snapshot, snapshot, FrameRenderer.renderElement, false, false); + const renderer = new rendererClass(this, this.view.snapshot, snapshot, false, false); if (this.view.renderPromise) await this.view.renderPromise; this.changeHistory(); @@ -6113,7 +6756,9 @@ class FrameController { #findFrameElement(element, submitter) { const id = getAttribute("data-turbo-frame", submitter, element) || this.element.getAttribute("target"); - return getFrameElementById(id) ?? this.element + const target = this.#getFrameElementById(id); + + return target instanceof FrameElement ? target : this.element } async extractForeignFrameElement(container) { @@ -6157,9 +6802,11 @@ class FrameController { } if (id) { - const frameElement = getFrameElementById(id); + const frameElement = this.#getFrameElementById(id); if (frameElement) { return !frameElement.disabled + } else if (id == "_parent") { + return false } } @@ -6180,8 +6827,12 @@ class FrameController { return this.element.id } + get disabled() { + return this.element.disabled + } + get enabled() { - return !this.element.disabled + return !this.disabled } get sourceURL() { @@ -6241,13 +6892,15 @@ class FrameController { callback(); delete this.currentNavigationElement; } -} -function getFrameElementById(id) { - if (id != null) { - const element = document.getElementById(id); - if (element instanceof FrameElement) { - return element + #getFrameElementById(id) { + if (id != null) { + const element = id === "_parent" ? + this.element.parentElement.closest("turbo-frame") : + document.getElementById(id); + if (element instanceof FrameElement) { + return element + } } } } @@ -6272,6 +6925,7 @@ function activateElement(element, currentURL) { const StreamActions = { after() { + this.removeDuplicateTargetSiblings(); this.targetElements.forEach((e) => e.parentElement?.insertBefore(this.templateContent, e.nextSibling)); }, @@ -6281,6 +6935,7 @@ const StreamActions = { }, before() { + this.removeDuplicateTargetSiblings(); this.targetElements.forEach((e) => e.parentElement?.insertBefore(this.templateContent, e)); }, @@ -6319,7 +6974,11 @@ const StreamActions = { }, refresh() { - session.refresh(this.baseURI, this.requestId); + const method = this.getAttribute("method"); + const requestId = this.requestId; + const scroll = this.getAttribute("scroll"); + + session.refresh(this.baseURI, { method, requestId, scroll }); } }; @@ -6391,7 +7050,24 @@ class StreamElement extends HTMLElement { * Gets the list of duplicate children (i.e. those with the same ID) */ get duplicateChildren() { - const existingChildren = this.targetElements.flatMap((e) => [...e.children]).filter((c) => !!c.id); + const existingChildren = this.targetElements.flatMap((e) => [...e.children]).filter((c) => !!c.getAttribute("id")); + const newChildrenIds = [...(this.templateContent?.children || [])].filter((c) => !!c.getAttribute("id")).map((c) => c.getAttribute("id")); + + return existingChildren.filter((c) => newChildrenIds.includes(c.getAttribute("id"))) + } + + /** + * Removes duplicate siblings (by ID) + */ + removeDuplicateTargetSiblings() { + this.duplicateSiblings.forEach((c) => c.remove()); + } + + /** + * Gets the list of duplicate siblings (i.e. those with the same ID) + */ + get duplicateSiblings() { + const existingChildren = this.targetElements.flatMap((e) => [...e.parentElement.children]).filter((c) => !!c.id); const newChildrenIds = [...(this.templateContent?.children || [])].filter((c) => !!c.id).map((c) => c.id); return existingChildren.filter((c) => newChildrenIds.includes(c.id)) @@ -6548,11 +7224,11 @@ if (customElements.get("turbo-stream-source") === undefined) { } (() => { - let element = document.currentScript; - if (!element) return - if (element.hasAttribute("data-turbo-suppress-warning")) return + const scriptElement = document.currentScript; + if (!scriptElement) return + if (scriptElement.hasAttribute("data-turbo-suppress-warning")) return - element = element.parentElement; + let element = scriptElement.parentElement; while (element) { if (element == document.body) { return console.warn( @@ -6566,7 +7242,7 @@ if (customElements.get("turbo-stream-source") === undefined) { —— Suppress this warning by adding a "data-turbo-suppress-warning" attribute to: %s `, - element.outerHTML + scriptElement.outerHTML ) } @@ -6577,4 +7253,4 @@ if (customElements.get("turbo-stream-source") === undefined) { window.Turbo = { ...Turbo, StreamActions }; start(); -export { FetchEnctype, FetchMethod, FetchRequest, FetchResponse, FrameElement, FrameLoadingStyle, FrameRenderer, PageRenderer, PageSnapshot, StreamActions, StreamElement, StreamSourceElement, cache, clearCache, connectStreamSource, disconnectStreamSource, fetchWithTurboHeaders as fetch, fetchEnctypeFromString, fetchMethodFromString, isSafe, navigator$1 as navigator, registerAdapter, renderStreamMessage, session, setConfirmMethod, setFormMode, setProgressBarDelay, start, visit }; +export { FetchEnctype, FetchMethod, FetchRequest, FetchResponse, FrameElement, FrameLoadingStyle, FrameRenderer, PageRenderer, PageSnapshot, StreamActions, StreamElement, StreamSourceElement, cache, config, connectStreamSource, disconnectStreamSource, fetchWithTurboHeaders as fetch, fetchEnctypeFromString, fetchMethodFromString, isSafe, morphBodyElements, morphChildren, morphElements, morphTurboFrameElements, sessionNavigator as navigator, registerAdapter, renderStreamMessage, session, setConfirmMethod, setFormMode, setProgressBarDelay, start, visit }; diff --git a/node_modules/@hotwired/turbo/dist/turbo.es2017-umd.js b/node_modules/@hotwired/turbo/dist/turbo.es2017-umd.js index 6235611fd0..120ed1143f 100644 --- a/node_modules/@hotwired/turbo/dist/turbo.es2017-umd.js +++ b/node_modules/@hotwired/turbo/dist/turbo.es2017-umd.js @@ -1,6 +1,6 @@ /*! -Turbo 8.0.5 -Copyright © 2024 37signals LLC +Turbo 8.0.23 +Copyright © 2026 37signals LLC */ (function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : @@ -8,103 +8,6 @@ Copyright © 2024 37signals LLC (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.Turbo = {})); })(this, (function (exports) { 'use strict'; - /** - * The MIT License (MIT) - * - * Copyright (c) 2019 Javan Makhmali - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - */ - - (function (prototype) { - if (typeof prototype.requestSubmit == "function") return - - prototype.requestSubmit = function (submitter) { - if (submitter) { - validateSubmitter(submitter, this); - submitter.click(); - } else { - submitter = document.createElement("input"); - submitter.type = "submit"; - submitter.hidden = true; - this.appendChild(submitter); - submitter.click(); - this.removeChild(submitter); - } - }; - - function validateSubmitter(submitter, form) { - submitter instanceof HTMLElement || raise(TypeError, "parameter 1 is not of type 'HTMLElement'"); - submitter.type == "submit" || raise(TypeError, "The specified element is not a submit button"); - submitter.form == form || - raise(DOMException, "The specified element is not owned by this form element", "NotFoundError"); - } - - function raise(errorConstructor, message, name) { - throw new errorConstructor("Failed to execute 'requestSubmit' on 'HTMLFormElement': " + message + ".", name) - } - })(HTMLFormElement.prototype); - - const submittersByForm = new WeakMap(); - - function findSubmitterFromClickTarget(target) { - const element = target instanceof Element ? target : target instanceof Node ? target.parentElement : null; - const candidate = element ? element.closest("input, button") : null; - return candidate?.type == "submit" ? candidate : null - } - - function clickCaptured(event) { - const submitter = findSubmitterFromClickTarget(event.target); - - if (submitter && submitter.form) { - submittersByForm.set(submitter.form, submitter); - } - } - - (function () { - if ("submitter" in Event.prototype) return - - let prototype = window.Event.prototype; - // Certain versions of Safari 15 have a bug where they won't - // populate the submitter. This hurts TurboDrive's enable/disable detection. - // See https://bugs.webkit.org/show_bug.cgi?id=229660 - if ("SubmitEvent" in window) { - const prototypeOfSubmitEvent = window.SubmitEvent.prototype; - - if (/Apple Computer/.test(navigator.vendor) && !("submitter" in prototypeOfSubmitEvent)) { - prototype = prototypeOfSubmitEvent; - } else { - return // polyfill not needed - } - } - - addEventListener("click", clickCaptured, true); - - Object.defineProperty(prototype, "submitter", { - get() { - if (this.type == "submit" && this.target instanceof HTMLFormElement) { - return submittersByForm.get(this.target) - } - } - }); - })(); - const FrameLoadingStyle = { eager: "eager", lazy: "lazy" @@ -198,6 +101,10 @@ Copyright © 2024 37signals LLC } } + get shouldReloadWithMorph() { + return this.src && this.refresh === "morph" + } + /** * Determines if the element is loading */ @@ -295,136 +202,27 @@ Copyright © 2024 37signals LLC } } - function expandURL(locatable) { - return new URL(locatable.toString(), document.baseURI) - } - - function getAnchor(url) { - let anchorMatch; - if (url.hash) { - return url.hash.slice(1) - // eslint-disable-next-line no-cond-assign - } else if ((anchorMatch = url.href.match(/#(.*)$/))) { - return anchorMatch[1] - } - } - - function getAction$1(form, submitter) { - const action = submitter?.getAttribute("formaction") || form.getAttribute("action") || form.action; - - return expandURL(action) - } - - function getExtension(url) { - return (getLastPathComponent(url).match(/\.[^.]*$/) || [])[0] || "" - } - - function isHTML(url) { - return !!getExtension(url).match(/^(?:|\.(?:htm|html|xhtml|php))$/) - } - - function isPrefixedBy(baseURL, url) { - const prefix = getPrefix(url); - return baseURL.href === expandURL(prefix).href || baseURL.href.startsWith(prefix) - } - - function locationIsVisitable(location, rootLocation) { - return isPrefixedBy(location, rootLocation) && isHTML(location) - } - - function getRequestURL(url) { - const anchor = getAnchor(url); - return anchor != null ? url.href.slice(0, -(anchor.length + 1)) : url.href - } - - function toCacheKey(url) { - return getRequestURL(url) - } - - function urlsAreEqual(left, right) { - return expandURL(left).href == expandURL(right).href - } - - function getPathComponents(url) { - return url.pathname.split("/").slice(1) - } - - function getLastPathComponent(url) { - return getPathComponents(url).slice(-1)[0] - } - - function getPrefix(url) { - return addTrailingSlash(url.origin + url.pathname) - } - - function addTrailingSlash(value) { - return value.endsWith("/") ? value : value + "/" - } - - class FetchResponse { - constructor(response) { - this.response = response; - } - - get succeeded() { - return this.response.ok - } - - get failed() { - return !this.succeeded - } - - get clientError() { - return this.statusCode >= 400 && this.statusCode <= 499 - } - - get serverError() { - return this.statusCode >= 500 && this.statusCode <= 599 - } - - get redirected() { - return this.response.redirected - } - - get location() { - return expandURL(this.response.url) - } - - get isHTML() { - return this.contentType && this.contentType.match(/^(?:text\/([^\s;,]+\b)?html|application\/xhtml\+xml)\b/) - } - - get statusCode() { - return this.response.status - } - - get contentType() { - return this.header("Content-Type") - } - - get responseText() { - return this.response.clone().text() - } - - get responseHTML() { - if (this.isHTML) { - return this.response.clone().text() - } else { - return Promise.resolve(undefined) - } - } - - header(name) { - return this.response.headers.get(name) - } - } + 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 = getMetaContent("csp-nonce"); + const cspNonce = getCspNonce(); if (cspNonce) { createdScriptElement.nonce = cspNonce; } @@ -464,6 +262,11 @@ Copyright © 2024 37signals LLC return event } + function cancelEvent(event) { + event.preventDefault(); + event.stopImmediatePropagation(); + } + function nextRepaint() { if (document.visibilityState === "hidden") { return nextEventLoopTick() @@ -480,10 +283,6 @@ Copyright © 2024 37signals LLC return new Promise((resolve) => setTimeout(() => resolve(), 0)) } - function nextMicrotask() { - return Promise.resolve() - } - function parseHTMLDocument(html = "") { return new DOMParser().parseFromString(html, "text/html") } @@ -512,7 +311,7 @@ Copyright © 2024 37signals LLC } else if (i == 19) { return (Math.floor(Math.random() * 4) + 8).toString(16) } else { - return Math.floor(Math.random() * 15).toString(16) + return Math.floor(Math.random() * 16).toString(16) } }) .join("") @@ -592,6 +391,15 @@ Copyright © 2024 37signals LLC return element && element.content } + function getCspNonce() { + const element = getMetaElement("csp-nonce"); + + if (element) { + const { nonce, content } = element; + return nonce == "" ? content : nonce + } + } + function setMetaContent(name, content) { let element = getMetaElement(name); @@ -652,11 +460,16 @@ Copyright © 2024 37signals LLC } function findLinkFromClickTarget(target) { - return findClosestRecursively(target, "a[href]:not([target^=_]):not([download])") - } + const link = findClosestRecursively(target, "a[href], a[xlink\\:href]"); - function getLocationForLink(link) { - return expandURL(link.getAttribute("href") || "") + if (!link) return null + if (link.href.startsWith("#")) return null + if (link.hasAttribute("download")) return null + + const linkTarget = link.getAttribute("target"); + if (linkTarget && linkTarget !== "_self") return null + + return link } function debounce(fn, delay) { @@ -669,6 +482,171 @@ Copyright © 2024 37signals LLC } } + const submitter = { + "aria-disabled": { + beforeSubmit: submitter => { + submitter.setAttribute("aria-disabled", "true"); + submitter.addEventListener("click", cancelEvent); + }, + + afterSubmit: submitter => { + submitter.removeAttribute("aria-disabled"); + submitter.removeEventListener("click", cancelEvent); + } + }, + + "disabled": { + beforeSubmit: submitter => submitter.disabled = true, + afterSubmit: submitter => submitter.disabled = false + } + }; + + class Config { + #submitter = null + + constructor(config) { + Object.assign(this, config); + } + + get submitter() { + return this.#submitter + } + + set submitter(value) { + this.#submitter = submitter[value] || value; + } + } + + const forms = new Config({ + mode: "on", + submitter: "disabled" + }); + + const config = { + drive, + forms + }; + + function expandURL(locatable) { + return new URL(locatable.toString(), document.baseURI) + } + + function getAnchor(url) { + let anchorMatch; + if (url.hash) { + return url.hash.slice(1) + // eslint-disable-next-line no-cond-assign + } else if ((anchorMatch = url.href.match(/#(.*)$/))) { + return anchorMatch[1] + } + } + + function getAction$1(form, submitter) { + const action = submitter?.getAttribute("formaction") || form.getAttribute("action") || form.action; + + return expandURL(action) + } + + function getExtension(url) { + return (getLastPathComponent(url).match(/\.[^.]*$/) || [])[0] || "" + } + + function isPrefixedBy(baseURL, url) { + const prefix = addTrailingSlash(url.origin + url.pathname); + return addTrailingSlash(baseURL.href) === prefix || baseURL.href.startsWith(prefix) + } + + function locationIsVisitable(location, rootLocation) { + return isPrefixedBy(location, rootLocation) && !config.drive.unvisitableExtensions.has(getExtension(location)) + } + + function getLocationForLink(link) { + return expandURL(link.getAttribute("href") || "") + } + + 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 addTrailingSlash(value) { + return value.endsWith("/") ? value : value + "/" + } + + class FetchResponse { + constructor(response) { + this.response = response; + } + + get succeeded() { + return this.response.ok + } + + get failed() { + return !this.succeeded + } + + get clientError() { + return this.statusCode >= 400 && this.statusCode <= 499 + } + + get serverError() { + return this.statusCode >= 500 && this.statusCode <= 599 + } + + get redirected() { + return this.response.redirected + } + + get location() { + return expandURL(this.response.url) + } + + get isHTML() { + return this.contentType && this.contentType.match(/^(?:text\/([^\s;,]+\b)?html|application\/xhtml\+xml)\b/) + } + + get statusCode() { + return this.response.status + } + + get contentType() { + return this.header("Content-Type") + } + + get responseText() { + return this.response.clone().text() + } + + get responseHTML() { + if (this.isHTML) { + return this.response.clone().text() + } else { + return Promise.resolve(undefined) + } + } + + header(name) { + return this.response.headers.get(name) + } + } + class LimitedSet extends Set { constructor(maxSize) { super(); @@ -687,15 +665,13 @@ Copyright © 2024 37signals LLC 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, { + return window.fetch(url, { ...options, headers: modifiedHeaders }) @@ -1003,35 +979,113 @@ Copyright © 2024 37signals LLC return fragment } - const PREFETCH_DELAY = 100; + const identity = key => key; - class PrefetchCache { - #prefetchTimeout = null - #prefetched = null + class LRUCache { + keys = [] + entries = {} + #toCacheKey - get(url) { - if (this.#prefetched && this.#prefetched.url === url && this.#prefetched.expire > Date.now()) { - return this.#prefetched.request + constructor(size, toCacheKey = identity) { + this.size = size; + this.#toCacheKey = toCacheKey; + } + + has(key) { + return this.#toCacheKey(key) in this.entries + } + + get(key) { + if (this.has(key)) { + const entry = this.read(key); + this.touch(key); + return entry } } - setLater(url, request, ttl) { - this.clear(); - - this.#prefetchTimeout = setTimeout(() => { - request.perform(); - this.set(url, request, ttl); - this.#prefetchTimeout = null; - }, PREFETCH_DELAY); - } - - set(url, request, ttl) { - this.#prefetched = { url, request, expire: new Date(new Date().getTime() + ttl) }; + put(key, entry) { + this.write(key, entry); + this.touch(key); + return entry } clear() { + for (const key of Object.keys(this.entries)) { + this.evict(key); + } + } + + // Private + + read(key) { + return this.entries[this.#toCacheKey(key)] + } + + write(key, entry) { + this.entries[this.#toCacheKey(key)] = entry; + } + + touch(key) { + key = this.#toCacheKey(key); + 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)) { + this.evict(key); + } + } + + evict(key) { + delete this.entries[key]; + } + } + + const PREFETCH_DELAY = 100; + + class PrefetchCache extends LRUCache { + #prefetchTimeout = null + #maxAges = {} + + constructor(size = 1, prefetchDelay = PREFETCH_DELAY) { + super(size, toCacheKey); + this.prefetchDelay = prefetchDelay; + } + + putLater(url, request, ttl) { + this.#prefetchTimeout = setTimeout(() => { + request.perform(); + this.put(url, request, ttl); + this.#prefetchTimeout = null; + }, this.prefetchDelay); + } + + put(url, request, ttl = cacheTtl) { + super.put(url, request); + this.#maxAges[toCacheKey(url)] = new Date(new Date().getTime() + ttl); + } + + clear() { + super.clear(); if (this.#prefetchTimeout) clearTimeout(this.#prefetchTimeout); - this.#prefetched = null; + } + + evict(key) { + super.evict(key); + delete this.#maxAges[key]; + } + + has(key) { + if (super.has(key)) { + const maxAge = this.#maxAges[toCacheKey(key)]; + + return maxAge && maxAge > Date.now() + } else { + return false + } } } @@ -1050,7 +1104,7 @@ Copyright © 2024 37signals LLC class FormSubmission { state = FormSubmissionState.initialized - static confirmMethod(message, _element, _submitter) { + static confirmMethod(message) { return Promise.resolve(confirm(message)) } @@ -1106,7 +1160,11 @@ Copyright © 2024 37signals LLC const confirmationMessage = getAttribute("data-turbo-confirm", this.submitter, this.formElement); if (typeof confirmationMessage === "string") { - const answer = await FormSubmission.confirmMethod(confirmationMessage, this.formElement, this.submitter); + const confirmMethod = typeof config.forms.confirm === "function" ? + config.forms.confirm : + FormSubmission.confirmMethod; + + const answer = await confirmMethod(confirmationMessage, this.formElement, this.submitter); if (!answer) { return } @@ -1144,7 +1202,7 @@ Copyright © 2024 37signals LLC requestStarted(_request) { this.state = FormSubmissionState.waiting; - this.submitter?.setAttribute("disabled", ""); + if (this.submitter) config.forms.submitter.beforeSubmit(this.submitter); this.setSubmitsWith(); markAsBusy(this.formElement); dispatch("turbo:submit-start", { @@ -1190,7 +1248,7 @@ Copyright © 2024 37signals LLC requestFinished(_request) { this.state = FormSubmissionState.stopped; - this.submitter?.removeAttribute("disabled"); + if (this.submitter) config.forms.submitter.afterSubmit(this.submitter); this.resetSubmitterText(); clearBusyState(this.formElement); dispatch("turbo:submit-end", { @@ -1427,8 +1485,8 @@ Copyright © 2024 37signals LLC scrollToAnchor(anchor) { const element = this.snapshot.getElementForAnchor(anchor); if (element) { - this.scrollToElement(element); this.focusElement(element); + this.scrollToElement(element); } else { this.scrollToPosition({ x: 0, y: 0 }); } @@ -1780,12 +1838,16 @@ Copyright © 2024 37signals LLC class Renderer { #activeElement = null - constructor(currentSnapshot, newSnapshot, renderElement, isPreview, willRender = true) { + 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 = renderElement; + this.renderElement = this.constructor.renderElement; this.promise = new Promise((resolve, reject) => (this.resolvingFunctions = { resolve, reject })); } @@ -1950,6 +2012,1514 @@ Copyright © 2024 37signals LLC } } + /** + * @typedef {object} ConfigHead + * + * @property {'merge' | 'append' | 'morph' | 'none'} [style] + * @property {boolean} [block] + * @property {boolean} [ignore] + * @property {function(Element): boolean} [shouldPreserve] + * @property {function(Element): boolean} [shouldReAppend] + * @property {function(Element): boolean} [shouldRemove] + * @property {function(Element, {added: Node[], kept: Element[], removed: Element[]}): void} [afterHeadMorphed] + */ + + /** + * @typedef {object} ConfigCallbacks + * + * @property {function(Node): boolean} [beforeNodeAdded] + * @property {function(Node): void} [afterNodeAdded] + * @property {function(Element, Node): boolean} [beforeNodeMorphed] + * @property {function(Element, Node): void} [afterNodeMorphed] + * @property {function(Element): boolean} [beforeNodeRemoved] + * @property {function(Element): void} [afterNodeRemoved] + * @property {function(string, Element, "update" | "remove"): boolean} [beforeAttributeUpdated] + */ + + /** + * @typedef {object} Config + * + * @property {'outerHTML' | 'innerHTML'} [morphStyle] + * @property {boolean} [ignoreActive] + * @property {boolean} [ignoreActiveValue] + * @property {boolean} [restoreFocus] + * @property {ConfigCallbacks} [callbacks] + * @property {ConfigHead} [head] + */ + + /** + * @typedef {function} NoOp + * + * @returns {void} + */ + + /** + * @typedef {object} ConfigHeadInternal + * + * @property {'merge' | 'append' | 'morph' | 'none'} style + * @property {boolean} [block] + * @property {boolean} [ignore] + * @property {(function(Element): boolean) | NoOp} shouldPreserve + * @property {(function(Element): boolean) | NoOp} shouldReAppend + * @property {(function(Element): boolean) | NoOp} shouldRemove + * @property {(function(Element, {added: Node[], kept: Element[], removed: Element[]}): void) | NoOp} afterHeadMorphed + */ + + /** + * @typedef {object} ConfigCallbacksInternal + * + * @property {(function(Node): boolean) | NoOp} beforeNodeAdded + * @property {(function(Node): void) | NoOp} afterNodeAdded + * @property {(function(Node, Node): boolean) | NoOp} beforeNodeMorphed + * @property {(function(Node, Node): void) | NoOp} afterNodeMorphed + * @property {(function(Node): boolean) | NoOp} beforeNodeRemoved + * @property {(function(Node): void) | NoOp} afterNodeRemoved + * @property {(function(string, Element, "update" | "remove"): boolean) | NoOp} beforeAttributeUpdated + */ + + /** + * @typedef {object} ConfigInternal + * + * @property {'outerHTML' | 'innerHTML'} morphStyle + * @property {boolean} [ignoreActive] + * @property {boolean} [ignoreActiveValue] + * @property {boolean} [restoreFocus] + * @property {ConfigCallbacksInternal} callbacks + * @property {ConfigHeadInternal} head + */ + + /** + * @typedef {Object} IdSets + * @property {Set} persistentIds + * @property {Map>} idMap + */ + + /** + * @typedef {Function} Morph + * + * @param {Element | Document} oldNode + * @param {Element | Node | HTMLCollection | Node[] | string | null} newContent + * @param {Config} [config] + * @returns {undefined | Node[]} + */ + + // base IIFE to define idiomorph + /** + * + * @type {{defaults: ConfigInternal, morph: Morph}} + */ + var Idiomorph = (function () { + + /** + * @typedef {object} MorphContext + * + * @property {Element} target + * @property {Element} newContent + * @property {ConfigInternal} config + * @property {ConfigInternal['morphStyle']} morphStyle + * @property {ConfigInternal['ignoreActive']} ignoreActive + * @property {ConfigInternal['ignoreActiveValue']} ignoreActiveValue + * @property {ConfigInternal['restoreFocus']} restoreFocus + * @property {Map>} idMap + * @property {Set} persistentIds + * @property {ConfigInternal['callbacks']} callbacks + * @property {ConfigInternal['head']} head + * @property {HTMLDivElement} pantry + * @property {Element[]} activeElementAndParents + */ + + //============================================================================= + // AND NOW IT BEGINS... + //============================================================================= + + const noOp = () => {}; + /** + * Default configuration values, updatable by users now + * @type {ConfigInternal} + */ + const defaults = { + morphStyle: "outerHTML", + callbacks: { + beforeNodeAdded: noOp, + afterNodeAdded: noOp, + beforeNodeMorphed: noOp, + afterNodeMorphed: noOp, + beforeNodeRemoved: noOp, + afterNodeRemoved: noOp, + beforeAttributeUpdated: noOp, + }, + head: { + style: "merge", + shouldPreserve: (elt) => elt.getAttribute("im-preserve") === "true", + shouldReAppend: (elt) => elt.getAttribute("im-re-append") === "true", + shouldRemove: noOp, + afterHeadMorphed: noOp, + }, + restoreFocus: true, + }; + + /** + * Core idiomorph function for morphing one DOM tree to another + * + * @param {Element | Document} oldNode + * @param {Element | Node | HTMLCollection | Node[] | string | null} newContent + * @param {Config} [config] + * @returns {Promise | Node[]} + */ + function morph(oldNode, newContent, config = {}) { + oldNode = normalizeElement(oldNode); + const newNode = normalizeParent(newContent); + const ctx = createMorphContext(oldNode, newNode, config); + + const morphedNodes = saveAndRestoreFocus(ctx, () => { + return withHeadBlocking( + ctx, + oldNode, + newNode, + /** @param {MorphContext} ctx */ (ctx) => { + if (ctx.morphStyle === "innerHTML") { + morphChildren(ctx, oldNode, newNode); + return Array.from(oldNode.childNodes); + } else { + return morphOuterHTML(ctx, oldNode, newNode); + } + }, + ); + }); + + ctx.pantry.remove(); + return morphedNodes; + } + + /** + * Morph just the outerHTML of the oldNode to the newContent + * We have to be careful because the oldNode could have siblings which need to be untouched + * @param {MorphContext} ctx + * @param {Element} oldNode + * @param {Element} newNode + * @returns {Node[]} + */ + function morphOuterHTML(ctx, oldNode, newNode) { + const oldParent = normalizeParent(oldNode); + morphChildren( + ctx, + oldParent, + newNode, + // these two optional params are the secret sauce + oldNode, // start point for iteration + oldNode.nextSibling, // end point for iteration + ); + // this is safe even with siblings, because normalizeParent returns a SlicedParentNode if needed. + return Array.from(oldParent.childNodes); + } + + /** + * @param {MorphContext} ctx + * @param {Function} fn + * @returns {Promise | Node[]} + */ + function saveAndRestoreFocus(ctx, fn) { + if (!ctx.config.restoreFocus) return fn(); + let activeElement = + /** @type {HTMLInputElement|HTMLTextAreaElement|null} */ ( + document.activeElement + ); + + // don't bother if the active element is not an input or textarea + if ( + !( + activeElement instanceof HTMLInputElement || + activeElement instanceof HTMLTextAreaElement + ) + ) { + return fn(); + } + + const { id: activeElementId, selectionStart, selectionEnd } = activeElement; + + const results = fn(); + + if ( + activeElementId && + activeElementId !== document.activeElement?.getAttribute("id") + ) { + activeElement = ctx.target.querySelector(`[id="${activeElementId}"]`); + activeElement?.focus(); + } + if (activeElement && !activeElement.selectionEnd && selectionEnd) { + activeElement.setSelectionRange(selectionStart, selectionEnd); + } + + return results; + } + + const morphChildren = (function () { + /** + * This is the core algorithm for matching up children. The idea is to use id sets to try to match up + * nodes as faithfully as possible. We greedily match, which allows us to keep the algorithm fast, but + * by using id sets, we are able to better match up with content deeper in the DOM. + * + * Basic algorithm: + * - for each node in the new content: + * - search self and siblings for an id set match, falling back to a soft match + * - if match found + * - remove any nodes up to the match: + * - pantry persistent nodes + * - delete the rest + * - morph the match + * - elsif no match found, and node is persistent + * - find its match by querying the old root (future) and pantry (past) + * - move it and its children here + * - morph it + * - else + * - create a new node from scratch as a last result + * + * @param {MorphContext} ctx the merge context + * @param {Element} oldParent the old content that we are merging the new content into + * @param {Element} newParent the parent element of the new content + * @param {Node|null} [insertionPoint] the point in the DOM we start morphing at (defaults to first child) + * @param {Node|null} [endPoint] the point in the DOM we stop morphing at (defaults to after last child) + */ + function morphChildren( + ctx, + oldParent, + newParent, + insertionPoint = null, + endPoint = null, + ) { + // normalize + if ( + oldParent instanceof HTMLTemplateElement && + newParent instanceof HTMLTemplateElement + ) { + // @ts-ignore we can pretend the DocumentFragment is an Element + oldParent = oldParent.content; + // @ts-ignore ditto + newParent = newParent.content; + } + insertionPoint ||= oldParent.firstChild; + + // run through all the new content + for (const newChild of newParent.childNodes) { + // once we reach the end of the old parent content skip to the end and insert the rest + if (insertionPoint && insertionPoint != endPoint) { + const bestMatch = findBestMatch( + ctx, + newChild, + insertionPoint, + endPoint, + ); + if (bestMatch) { + // if the node to morph is not at the insertion point then remove/move up to it + if (bestMatch !== insertionPoint) { + removeNodesBetween(ctx, insertionPoint, bestMatch); + } + morphNode(bestMatch, newChild, ctx); + insertionPoint = bestMatch.nextSibling; + continue; + } + } + + // if the matching node is elsewhere in the original content + if (newChild instanceof Element) { + // we can pretend the id is non-null because the next `.has` line will reject it if not + const newChildId = /** @type {String} */ ( + newChild.getAttribute("id") + ); + if (ctx.persistentIds.has(newChildId)) { + // move it and all its children here and morph + const movedChild = moveBeforeById( + oldParent, + newChildId, + insertionPoint, + ctx, + ); + morphNode(movedChild, newChild, ctx); + insertionPoint = movedChild.nextSibling; + continue; + } + } + + // last resort: insert the new node from scratch + const insertedNode = createNode( + oldParent, + newChild, + insertionPoint, + ctx, + ); + // could be null if beforeNodeAdded prevented insertion + if (insertedNode) { + insertionPoint = insertedNode.nextSibling; + } + } + + // remove any remaining old nodes that didn't match up with new content + while (insertionPoint && insertionPoint != endPoint) { + const tempNode = insertionPoint; + insertionPoint = insertionPoint.nextSibling; + removeNode(ctx, tempNode); + } + } + + /** + * This performs the action of inserting a new node while handling situations where the node contains + * elements with persistent ids and possible state info we can still preserve by moving in and then morphing + * + * @param {Element} oldParent + * @param {Node} newChild + * @param {Node|null} insertionPoint + * @param {MorphContext} ctx + * @returns {Node|null} + */ + function createNode(oldParent, newChild, insertionPoint, ctx) { + if (ctx.callbacks.beforeNodeAdded(newChild) === false) return null; + if (ctx.idMap.has(newChild)) { + // node has children with ids with possible state so create a dummy elt of same type and apply full morph algorithm + const newEmptyChild = document.createElement( + /** @type {Element} */ (newChild).tagName, + ); + oldParent.insertBefore(newEmptyChild, insertionPoint); + morphNode(newEmptyChild, newChild, ctx); + ctx.callbacks.afterNodeAdded(newEmptyChild); + return newEmptyChild; + } else { + // optimisation: no id state to preserve so we can just insert a clone of the newChild and its descendants + const newClonedChild = document.importNode(newChild, true); // importNode to not mutate newParent + oldParent.insertBefore(newClonedChild, insertionPoint); + ctx.callbacks.afterNodeAdded(newClonedChild); + return newClonedChild; + } + } + + //============================================================================= + // Matching Functions + //============================================================================= + const findBestMatch = (function () { + /** + * Scans forward from the startPoint to the endPoint looking for a match + * for the node. It looks for an id set match first, then a soft match. + * We abort softmatching if we find two future soft matches, to reduce churn. + * @param {Node} node + * @param {MorphContext} ctx + * @param {Node | null} startPoint + * @param {Node | null} endPoint + * @returns {Node | null} + */ + function findBestMatch(ctx, node, startPoint, endPoint) { + let softMatch = null; + let nextSibling = node.nextSibling; + let siblingSoftMatchCount = 0; + + let cursor = startPoint; + while (cursor && cursor != endPoint) { + // soft matching is a prerequisite for id set matching + if (isSoftMatch(cursor, node)) { + if (isIdSetMatch(ctx, cursor, node)) { + return cursor; // found an id set match, we're done! + } + + // we haven't yet saved a soft match fallback + if (softMatch === null) { + // the current soft match will hard match something else in the future, leave it + if (!ctx.idMap.has(cursor)) { + // save this as the fallback if we get through the loop without finding a hard match + softMatch = cursor; + } + } + } + if ( + softMatch === null && + nextSibling && + isSoftMatch(cursor, nextSibling) + ) { + // The next new node has a soft match with this node, so + // increment the count of future soft matches + siblingSoftMatchCount++; + nextSibling = nextSibling.nextSibling; + + // If there are two future soft matches, block soft matching for this node to allow + // future siblings to soft match. This is to reduce churn in the DOM when an element + // is prepended. + if (siblingSoftMatchCount >= 2) { + softMatch = undefined; + } + } + + // if the current node contains active element, stop looking for better future matches, + // because if one is found, this node will be moved to the pantry, reparenting it and thus losing focus + // @ts-ignore pretend cursor is Element rather than Node, we're just testing for array inclusion + if (ctx.activeElementAndParents.includes(cursor)) break; + + cursor = cursor.nextSibling; + } + + return softMatch || null; + } + + /** + * + * @param {MorphContext} ctx + * @param {Node} oldNode + * @param {Node} newNode + * @returns {boolean} + */ + function isIdSetMatch(ctx, oldNode, newNode) { + let oldSet = ctx.idMap.get(oldNode); + let newSet = ctx.idMap.get(newNode); + + if (!newSet || !oldSet) return false; + + for (const id of oldSet) { + // a potential match is an id in the new and old nodes that + // has not already been merged into the DOM + // But the newNode content we call this on has not been + // merged yet and we don't allow duplicate IDs so it is simple + if (newSet.has(id)) { + return true; + } + } + return false; + } + + /** + * + * @param {Node} oldNode + * @param {Node} newNode + * @returns {boolean} + */ + function isSoftMatch(oldNode, newNode) { + // ok to cast: if one is not element, `id` and `tagName` will be undefined and we'll just compare that. + const oldElt = /** @type {Element} */ (oldNode); + const newElt = /** @type {Element} */ (newNode); + + return ( + oldElt.nodeType === newElt.nodeType && + oldElt.tagName === newElt.tagName && + // If oldElt has an `id` with possible state and it doesn't match newElt.id then avoid morphing. + // We'll still match an anonymous node with an IDed newElt, though, because if it got this far, + // its not persistent, and new nodes can't have any hidden state. + // We can't use .id because of form input shadowing, and we can't count on .getAttribute's presence because it could be a document-fragment + (!oldElt.getAttribute?.("id") || + oldElt.getAttribute?.("id") === newElt.getAttribute?.("id")) + ); + } + + return findBestMatch; + })(); + + //============================================================================= + // DOM Manipulation Functions + //============================================================================= + + /** + * Gets rid of an unwanted DOM node; strategy depends on nature of its reuse: + * - Persistent nodes will be moved to the pantry for later reuse + * - Other nodes will have their hooks called, and then are removed + * @param {MorphContext} ctx + * @param {Node} node + */ + function removeNode(ctx, node) { + // are we going to id set match this later? + if (ctx.idMap.has(node)) { + // skip callbacks and move to pantry + moveBefore(ctx.pantry, node, null); + } else { + // remove for realsies + if (ctx.callbacks.beforeNodeRemoved(node) === false) return; + node.parentNode?.removeChild(node); + ctx.callbacks.afterNodeRemoved(node); + } + } + + /** + * Remove nodes between the start and end nodes + * @param {MorphContext} ctx + * @param {Node} startInclusive + * @param {Node} endExclusive + * @returns {Node|null} + */ + function removeNodesBetween(ctx, startInclusive, endExclusive) { + /** @type {Node | null} */ + let cursor = startInclusive; + // remove nodes until the endExclusive node + while (cursor && cursor !== endExclusive) { + let tempNode = /** @type {Node} */ (cursor); + cursor = cursor.nextSibling; + removeNode(ctx, tempNode); + } + return cursor; + } + + /** + * Search for an element by id within the document and pantry, and move it using moveBefore. + * + * @param {Element} parentNode - The parent node to which the element will be moved. + * @param {string} id - The ID of the element to be moved. + * @param {Node | null} after - The reference node to insert the element before. + * If `null`, the element is appended as the last child. + * @param {MorphContext} ctx + * @returns {Element} The found element + */ + function moveBeforeById(parentNode, id, after, ctx) { + const target = + /** @type {Element} - will always be found */ + ( + // ctx.target.id unsafe because of form input shadowing + // ctx.target could be a document fragment which doesn't have `getAttribute` + (ctx.target.getAttribute?.("id") === id && ctx.target) || + ctx.target.querySelector(`[id="${id}"]`) || + ctx.pantry.querySelector(`[id="${id}"]`) + ); + removeElementFromAncestorsIdMaps(target, ctx); + moveBefore(parentNode, target, after); + return target; + } + + /** + * Removes an element from its ancestors' id maps. This is needed when an element is moved from the + * "future" via `moveBeforeId`. Otherwise, its erstwhile ancestors could be mistakenly moved to the + * pantry rather than being deleted, preventing their removal hooks from being called. + * + * @param {Element} element - element to remove from its ancestors' id maps + * @param {MorphContext} ctx + */ + function removeElementFromAncestorsIdMaps(element, ctx) { + // we know id is non-null String, because this function is only called on elements with ids + const id = /** @type {String} */ (element.getAttribute("id")); + /** @ts-ignore - safe to loop in this way **/ + while ((element = element.parentNode)) { + let idSet = ctx.idMap.get(element); + if (idSet) { + idSet.delete(id); + if (!idSet.size) { + ctx.idMap.delete(element); + } + } + } + } + + /** + * Moves an element before another element within the same parent. + * Uses the proposed `moveBefore` API if available (and working), otherwise falls back to `insertBefore`. + * This is essentialy a forward-compat wrapper. + * + * @param {Element} parentNode - The parent node containing the after element. + * @param {Node} element - The element to be moved. + * @param {Node | null} after - The reference node to insert `element` before. + * If `null`, `element` is appended as the last child. + */ + function moveBefore(parentNode, element, after) { + // @ts-ignore - use proposed moveBefore feature + if (parentNode.moveBefore) { + try { + // @ts-ignore - use proposed moveBefore feature + parentNode.moveBefore(element, after); + } catch (e) { + // fall back to insertBefore as some browsers may fail on moveBefore when trying to move Dom disconnected nodes to pantry + parentNode.insertBefore(element, after); + } + } else { + parentNode.insertBefore(element, after); + } + } + + return morphChildren; + })(); + + //============================================================================= + // Single Node Morphing Code + //============================================================================= + const morphNode = (function () { + /** + * @param {Node} oldNode root node to merge content into + * @param {Node} newContent new content to merge + * @param {MorphContext} ctx the merge context + * @returns {Node | null} the element that ended up in the DOM + */ + function morphNode(oldNode, newContent, ctx) { + if (ctx.ignoreActive && oldNode === document.activeElement) { + // don't morph focused element + return null; + } + + if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) { + return oldNode; + } + + if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) ; else if ( + oldNode instanceof HTMLHeadElement && + ctx.head.style !== "morph" + ) { + // ok to cast: if newContent wasn't also a , it would've got caught in the `!isSoftMatch` branch above + handleHeadElement( + oldNode, + /** @type {HTMLHeadElement} */ (newContent), + ctx, + ); + } else { + morphAttributes(oldNode, newContent, ctx); + if (!ignoreValueOfActiveElement(oldNode, ctx)) { + // @ts-ignore newContent can be a node here because .firstChild will be null + morphChildren(ctx, oldNode, newContent); + } + } + ctx.callbacks.afterNodeMorphed(oldNode, newContent); + return oldNode; + } + + /** + * syncs the oldNode to the newNode, copying over all attributes and + * inner element state from the newNode to the oldNode + * + * @param {Node} oldNode the node to copy attributes & state to + * @param {Node} newNode the node to copy attributes & state from + * @param {MorphContext} ctx the merge context + */ + function morphAttributes(oldNode, newNode, ctx) { + let type = newNode.nodeType; + + // if is an element type, sync the attributes from the + // new node into the new node + if (type === 1 /* element type */) { + const oldElt = /** @type {Element} */ (oldNode); + const newElt = /** @type {Element} */ (newNode); + + const oldAttributes = oldElt.attributes; + const newAttributes = newElt.attributes; + for (const newAttribute of newAttributes) { + if (ignoreAttribute(newAttribute.name, oldElt, "update", ctx)) { + continue; + } + if (oldElt.getAttribute(newAttribute.name) !== newAttribute.value) { + oldElt.setAttribute(newAttribute.name, newAttribute.value); + } + } + // iterate backwards to avoid skipping over items when a delete occurs + for (let i = oldAttributes.length - 1; 0 <= i; i--) { + const oldAttribute = oldAttributes[i]; + + // toAttributes is a live NamedNodeMap, so iteration+mutation is unsafe + // e.g. custom element attribute callbacks can remove other attributes + if (!oldAttribute) continue; + + if (!newElt.hasAttribute(oldAttribute.name)) { + if (ignoreAttribute(oldAttribute.name, oldElt, "remove", ctx)) { + continue; + } + oldElt.removeAttribute(oldAttribute.name); + } + } + + if (!ignoreValueOfActiveElement(oldElt, ctx)) { + syncInputValue(oldElt, newElt, ctx); + } + } + + // sync text nodes + if (type === 8 /* comment */ || type === 3 /* text */) { + if (oldNode.nodeValue !== newNode.nodeValue) { + oldNode.nodeValue = newNode.nodeValue; + } + } + } + + /** + * NB: many bothans died to bring us information: + * + * https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js + * https://github.com/choojs/nanomorph/blob/master/lib/morph.jsL113 + * + * @param {Element} oldElement the element to sync the input value to + * @param {Element} newElement the element to sync the input value from + * @param {MorphContext} ctx the merge context + */ + function syncInputValue(oldElement, newElement, ctx) { + if ( + oldElement instanceof HTMLInputElement && + newElement instanceof HTMLInputElement && + newElement.type !== "file" + ) { + let newValue = newElement.value; + let oldValue = oldElement.value; + + // sync boolean attributes + syncBooleanAttribute(oldElement, newElement, "checked", ctx); + syncBooleanAttribute(oldElement, newElement, "disabled", ctx); + + if (!newElement.hasAttribute("value")) { + if (!ignoreAttribute("value", oldElement, "remove", ctx)) { + oldElement.value = ""; + oldElement.removeAttribute("value"); + } + } else if (oldValue !== newValue) { + if (!ignoreAttribute("value", oldElement, "update", ctx)) { + oldElement.setAttribute("value", newValue); + oldElement.value = newValue; + } + } + // TODO: QUESTION(1cg): this used to only check `newElement` unlike the other branches -- why? + // did I break something? + } else if ( + oldElement instanceof HTMLOptionElement && + newElement instanceof HTMLOptionElement + ) { + syncBooleanAttribute(oldElement, newElement, "selected", ctx); + } else if ( + oldElement instanceof HTMLTextAreaElement && + newElement instanceof HTMLTextAreaElement + ) { + let newValue = newElement.value; + let oldValue = oldElement.value; + if (ignoreAttribute("value", oldElement, "update", ctx)) { + return; + } + if (newValue !== oldValue) { + oldElement.value = newValue; + } + if ( + oldElement.firstChild && + oldElement.firstChild.nodeValue !== newValue + ) { + oldElement.firstChild.nodeValue = newValue; + } + } + } + + /** + * @param {Element} oldElement element to write the value to + * @param {Element} newElement element to read the value from + * @param {string} attributeName the attribute name + * @param {MorphContext} ctx the merge context + */ + function syncBooleanAttribute(oldElement, newElement, attributeName, ctx) { + // @ts-ignore this function is only used on boolean attrs that are reflected as dom properties + const newLiveValue = newElement[attributeName], + // @ts-ignore ditto + oldLiveValue = oldElement[attributeName]; + if (newLiveValue !== oldLiveValue) { + const ignoreUpdate = ignoreAttribute( + attributeName, + oldElement, + "update", + ctx, + ); + if (!ignoreUpdate) { + // update attribute's associated DOM property + // @ts-ignore this function is only used on boolean attrs that are reflected as dom properties + oldElement[attributeName] = newElement[attributeName]; + } + if (newLiveValue) { + if (!ignoreUpdate) { + // https://developer.mozilla.org/en-US/docs/Glossary/Boolean/HTML + // this is the correct way to set a boolean attribute to "true" + oldElement.setAttribute(attributeName, ""); + } + } else { + if (!ignoreAttribute(attributeName, oldElement, "remove", ctx)) { + oldElement.removeAttribute(attributeName); + } + } + } + } + + /** + * @param {string} attr the attribute to be mutated + * @param {Element} element the element that is going to be updated + * @param {"update" | "remove"} updateType + * @param {MorphContext} ctx the merge context + * @returns {boolean} true if the attribute should be ignored, false otherwise + */ + function ignoreAttribute(attr, element, updateType, ctx) { + if ( + attr === "value" && + ctx.ignoreActiveValue && + element === document.activeElement + ) { + return true; + } + return ( + ctx.callbacks.beforeAttributeUpdated(attr, element, updateType) === + false + ); + } + + /** + * @param {Node} possibleActiveElement + * @param {MorphContext} ctx + * @returns {boolean} + */ + function ignoreValueOfActiveElement(possibleActiveElement, ctx) { + return ( + !!ctx.ignoreActiveValue && + possibleActiveElement === document.activeElement && + possibleActiveElement !== document.body + ); + } + + return morphNode; + })(); + + //============================================================================= + // Head Management Functions + //============================================================================= + /** + * @param {MorphContext} ctx + * @param {Element} oldNode + * @param {Element} newNode + * @param {function} callback + * @returns {Node[] | Promise} + */ + function withHeadBlocking(ctx, oldNode, newNode, callback) { + if (ctx.head.block) { + const oldHead = oldNode.querySelector("head"); + const newHead = newNode.querySelector("head"); + if (oldHead && newHead) { + const promises = handleHeadElement(oldHead, newHead, ctx); + // when head promises resolve, proceed ignoring the head tag + return Promise.all(promises).then(() => { + const newCtx = Object.assign(ctx, { + head: { + block: false, + ignore: true, + }, + }); + return callback(newCtx); + }); + } + } + // just proceed if we not head blocking + return callback(ctx); + } + + /** + * The HEAD tag can be handled specially, either w/ a 'merge' or 'append' style + * + * @param {Element} oldHead + * @param {Element} newHead + * @param {MorphContext} ctx + * @returns {Promise[]} + */ + function handleHeadElement(oldHead, newHead, ctx) { + let added = []; + let removed = []; + let preserved = []; + let nodesToAppend = []; + + // put all new head elements into a Map, by their outerHTML + let srcToNewHeadNodes = new Map(); + for (const newHeadChild of newHead.children) { + srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild); + } + + // for each elt in the current head + for (const currentHeadElt of oldHead.children) { + // If the current head element is in the map + let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML); + let isReAppended = ctx.head.shouldReAppend(currentHeadElt); + let isPreserved = ctx.head.shouldPreserve(currentHeadElt); + if (inNewContent || isPreserved) { + if (isReAppended) { + // remove the current version and let the new version replace it and re-execute + removed.push(currentHeadElt); + } else { + // this element already exists and should not be re-appended, so remove it from + // the new content map, preserving it in the DOM + srcToNewHeadNodes.delete(currentHeadElt.outerHTML); + preserved.push(currentHeadElt); + } + } else { + if (ctx.head.style === "append") { + // we are appending and this existing element is not new content + // so if and only if it is marked for re-append do we do anything + if (isReAppended) { + removed.push(currentHeadElt); + nodesToAppend.push(currentHeadElt); + } + } else { + // if this is a merge, we remove this content since it is not in the new head + if (ctx.head.shouldRemove(currentHeadElt) !== false) { + removed.push(currentHeadElt); + } + } + } + } + + // Push the remaining new head elements in the Map into the + // nodes to append to the head tag + nodesToAppend.push(...srcToNewHeadNodes.values()); + + let promises = []; + for (const newNode of nodesToAppend) { + // TODO: This could theoretically be null, based on type + let newElt = /** @type {ChildNode} */ ( + document.createRange().createContextualFragment(newNode.outerHTML) + .firstChild + ); + if (ctx.callbacks.beforeNodeAdded(newElt) !== false) { + if ( + ("href" in newElt && newElt.href) || + ("src" in newElt && newElt.src) + ) { + /** @type {(result?: any) => void} */ let resolve; + let promise = new Promise(function (_resolve) { + resolve = _resolve; + }); + newElt.addEventListener("load", function () { + resolve(); + }); + promises.push(promise); + } + oldHead.appendChild(newElt); + ctx.callbacks.afterNodeAdded(newElt); + added.push(newElt); + } + } + + // remove all removed elements, after we have appended the new elements to avoid + // additional network requests for things like style sheets + for (const removedElement of removed) { + if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) { + oldHead.removeChild(removedElement); + ctx.callbacks.afterNodeRemoved(removedElement); + } + } + + ctx.head.afterHeadMorphed(oldHead, { + added: added, + kept: preserved, + removed: removed, + }); + return promises; + } + + //============================================================================= + // Create Morph Context Functions + //============================================================================= + const createMorphContext = (function () { + /** + * + * @param {Element} oldNode + * @param {Element} newContent + * @param {Config} config + * @returns {MorphContext} + */ + function createMorphContext(oldNode, newContent, config) { + const { persistentIds, idMap } = createIdMaps(oldNode, newContent); + + const mergedConfig = mergeDefaults(config); + const morphStyle = mergedConfig.morphStyle || "outerHTML"; + if (!["innerHTML", "outerHTML"].includes(morphStyle)) { + throw `Do not understand how to morph style ${morphStyle}`; + } + + return { + target: oldNode, + newContent: newContent, + config: mergedConfig, + morphStyle: morphStyle, + ignoreActive: mergedConfig.ignoreActive, + ignoreActiveValue: mergedConfig.ignoreActiveValue, + restoreFocus: mergedConfig.restoreFocus, + idMap: idMap, + persistentIds: persistentIds, + pantry: createPantry(), + activeElementAndParents: createActiveElementAndParents(oldNode), + callbacks: mergedConfig.callbacks, + head: mergedConfig.head, + }; + } + + /** + * Deep merges the config object and the Idiomorph.defaults object to + * produce a final configuration object + * @param {Config} config + * @returns {ConfigInternal} + */ + function mergeDefaults(config) { + let finalConfig = Object.assign({}, defaults); + + // copy top level stuff into final config + Object.assign(finalConfig, config); + + // copy callbacks into final config (do this to deep merge the callbacks) + finalConfig.callbacks = Object.assign( + {}, + defaults.callbacks, + config.callbacks, + ); + + // copy head config into final config (do this to deep merge the head) + finalConfig.head = Object.assign({}, defaults.head, config.head); + + return finalConfig; + } + + /** + * @returns {HTMLDivElement} + */ + function createPantry() { + const pantry = document.createElement("div"); + pantry.hidden = true; + document.body.insertAdjacentElement("afterend", pantry); + return pantry; + } + + /** + * @param {Element} oldNode + * @returns {Element[]} + */ + function createActiveElementAndParents(oldNode) { + /** @type {Element[]} */ + let activeElementAndParents = []; + let elt = document.activeElement; + if (elt?.tagName !== "BODY" && oldNode.contains(elt)) { + while (elt) { + activeElementAndParents.push(elt); + if (elt === oldNode) break; + elt = elt.parentElement; + } + } + return activeElementAndParents; + } + + /** + * Returns all elements with an ID contained within the root element and its descendants + * + * @param {Element} root + * @returns {Element[]} + */ + function findIdElements(root) { + let elements = Array.from(root.querySelectorAll("[id]")); + // root could be a document fragment which doesn't have `getAttribute` + if (root.getAttribute?.("id")) { + elements.push(root); + } + return elements; + } + + /** + * A bottom-up algorithm that populates a map of Element -> IdSet. + * The idSet for a given element is the set of all IDs contained within its subtree. + * As an optimzation, we filter these IDs through the given list of persistent IDs, + * because we don't need to bother considering IDed elements that won't be in the new content. + * + * @param {Map>} idMap + * @param {Set} persistentIds + * @param {Element} root + * @param {Element[]} elements + */ + function populateIdMapWithTree(idMap, persistentIds, root, elements) { + for (const elt of elements) { + // we can pretend id is non-null String, because the .has line will reject it immediately if not + const id = /** @type {String} */ (elt.getAttribute("id")); + if (persistentIds.has(id)) { + /** @type {Element|null} */ + let current = elt; + // walk up the parent hierarchy of that element, adding the id + // of element to the parent's id set + while (current) { + let idSet = idMap.get(current); + // if the id set doesn't exist, create it and insert it in the map + if (idSet == null) { + idSet = new Set(); + idMap.set(current, idSet); + } + idSet.add(id); + + if (current === root) break; + current = current.parentElement; + } + } + } + } + + /** + * This function computes a map of nodes to all ids contained within that node (inclusive of the + * node). This map can be used to ask if two nodes have intersecting sets of ids, which allows + * for a looser definition of "matching" than tradition id matching, and allows child nodes + * to contribute to a parent nodes matching. + * + * @param {Element} oldContent the old content that will be morphed + * @param {Element} newContent the new content to morph to + * @returns {IdSets} + */ + function createIdMaps(oldContent, newContent) { + const oldIdElements = findIdElements(oldContent); + const newIdElements = findIdElements(newContent); + + const persistentIds = createPersistentIds(oldIdElements, newIdElements); + + /** @type {Map>} */ + let idMap = new Map(); + populateIdMapWithTree(idMap, persistentIds, oldContent, oldIdElements); + + /** @ts-ignore - if newContent is a duck-typed parent, pass its single child node as the root to halt upwards iteration */ + const newRoot = newContent.__idiomorphRoot || newContent; + populateIdMapWithTree(idMap, persistentIds, newRoot, newIdElements); + + return { persistentIds, idMap }; + } + + /** + * This function computes the set of ids that persist between the two contents excluding duplicates + * + * @param {Element[]} oldIdElements + * @param {Element[]} newIdElements + * @returns {Set} + */ + function createPersistentIds(oldIdElements, newIdElements) { + let duplicateIds = new Set(); + + /** @type {Map} */ + let oldIdTagNameMap = new Map(); + for (const { id, tagName } of oldIdElements) { + if (oldIdTagNameMap.has(id)) { + duplicateIds.add(id); + } else { + oldIdTagNameMap.set(id, tagName); + } + } + + let persistentIds = new Set(); + for (const { id, tagName } of newIdElements) { + if (persistentIds.has(id)) { + duplicateIds.add(id); + } else if (oldIdTagNameMap.get(id) === tagName) { + persistentIds.add(id); + } + // skip if tag types mismatch because its not possible to morph one tag into another + } + + for (const id of duplicateIds) { + persistentIds.delete(id); + } + return persistentIds; + } + + return createMorphContext; + })(); + + //============================================================================= + // HTML Normalization Functions + //============================================================================= + const { normalizeElement, normalizeParent } = (function () { + /** @type {WeakSet} */ + const generatedByIdiomorph = new WeakSet(); + + /** + * + * @param {Element | Document} content + * @returns {Element} + */ + function normalizeElement(content) { + if (content instanceof Document) { + return content.documentElement; + } else { + return content; + } + } + + /** + * + * @param {null | string | Node | HTMLCollection | Node[] | Document & {generatedByIdiomorph:boolean}} newContent + * @returns {Element} + */ + function normalizeParent(newContent) { + if (newContent == null) { + return document.createElement("div"); // dummy parent element + } else if (typeof newContent === "string") { + return normalizeParent(parseContent(newContent)); + } else if ( + generatedByIdiomorph.has(/** @type {Element} */ (newContent)) + ) { + // the template tag created by idiomorph parsing can serve as a dummy parent + return /** @type {Element} */ (newContent); + } else if (newContent instanceof Node) { + if (newContent.parentNode) { + // we can't use the parent directly because newContent may have siblings + // that we don't want in the morph, and reparenting might be expensive (TODO is it?), + // so instead we create a fake parent node that only sees a slice of its children. + /** @type {Element} */ + return /** @type {any} */ (new SlicedParentNode(newContent)); + } else { + // a single node is added as a child to a dummy parent + const dummyParent = document.createElement("div"); + dummyParent.append(newContent); + return dummyParent; + } + } else { + // all nodes in the array or HTMLElement collection are consolidated under + // a single dummy parent element + const dummyParent = document.createElement("div"); + for (const elt of [...newContent]) { + dummyParent.append(elt); + } + return dummyParent; + } + } + + /** + * A fake duck-typed parent element to wrap a single node, without actually reparenting it. + * This is useful because the node may have siblings that we don't want in the morph, and it may also be moved + * or replaced with one or more elements during the morph. This class effectively allows us a window into + * a slice of a node's children. + * "If it walks like a duck, and quacks like a duck, then it must be a duck!" -- James Whitcomb Riley (1849–1916) + */ + class SlicedParentNode { + /** @param {Node} node */ + constructor(node) { + this.originalNode = node; + this.realParentNode = /** @type {Element} */ (node.parentNode); + this.previousSibling = node.previousSibling; + this.nextSibling = node.nextSibling; + } + + /** @returns {Node[]} */ + get childNodes() { + // return slice of realParent's current childNodes, based on previousSibling and nextSibling + const nodes = []; + let cursor = this.previousSibling + ? this.previousSibling.nextSibling + : this.realParentNode.firstChild; + while (cursor && cursor != this.nextSibling) { + nodes.push(cursor); + cursor = cursor.nextSibling; + } + return nodes; + } + + /** + * @param {string} selector + * @returns {Element[]} + */ + querySelectorAll(selector) { + return this.childNodes.reduce((results, node) => { + if (node instanceof Element) { + if (node.matches(selector)) results.push(node); + const nodeList = node.querySelectorAll(selector); + for (let i = 0; i < nodeList.length; i++) { + results.push(nodeList[i]); + } + } + return results; + }, /** @type {Element[]} */ ([])); + } + + /** + * @param {Node} node + * @param {Node} referenceNode + * @returns {Node} + */ + insertBefore(node, referenceNode) { + return this.realParentNode.insertBefore(node, referenceNode); + } + + /** + * @param {Node} node + * @param {Node} referenceNode + * @returns {Node} + */ + moveBefore(node, referenceNode) { + // @ts-ignore - use new moveBefore feature + return this.realParentNode.moveBefore(node, referenceNode); + } + + /** + * for later use with populateIdMapWithTree to halt upwards iteration + * @returns {Node} + */ + get __idiomorphRoot() { + return this.originalNode; + } + } + + /** + * + * @param {string} newContent + * @returns {Node | null | DocumentFragment} + */ + function parseContent(newContent) { + let parser = new DOMParser(); + + // remove svgs to avoid false-positive matches on head, etc. + let contentWithSvgsRemoved = newContent.replace( + /]*>|>)([\s\S]*?)<\/svg>/gim, + "", + ); + + // if the newContent contains a html, head or body tag, we can simply parse it w/o wrapping + if ( + contentWithSvgsRemoved.match(/<\/html>/) || + contentWithSvgsRemoved.match(/<\/head>/) || + contentWithSvgsRemoved.match(/<\/body>/) + ) { + let content = parser.parseFromString(newContent, "text/html"); + // if it is a full HTML document, return the document itself as the parent container + if (contentWithSvgsRemoved.match(/<\/html>/)) { + generatedByIdiomorph.add(content); + return content; + } else { + // otherwise return the html element as the parent container + let htmlElement = content.firstChild; + if (htmlElement) { + generatedByIdiomorph.add(htmlElement); + } + return htmlElement; + } + } else { + // if it is partial HTML, wrap it in a template tag to provide a parent element and also to help + // deal with touchy tags like tr, tbody, etc. + let responseDoc = parser.parseFromString( + "", + "text/html", + ); + let content = /** @type {HTMLTemplateElement} */ ( + responseDoc.body.querySelector("template") + ).content; + generatedByIdiomorph.add(content); + return content; + } + } + + return { normalizeElement, normalizeParent }; + })(); + + //============================================================================= + // This is what ends up becoming the Idiomorph global object + //============================================================================= + return { + morph, + defaults, + }; + })(); + + /** + * Morph the state of the currentElement based on the attributes and contents of + * the newElement. Morphing may dispatch turbo:before-morph-element, + * turbo:before-morph-attribute, and turbo:morph-element events. + * + * @param currentElement Element destination of morphing changes + * @param newElement Element source of morphing changes + */ + function morphElements(currentElement, newElement, { callbacks, ...options } = {}) { + Idiomorph.morph(currentElement, newElement, { + ...options, + callbacks: new DefaultIdiomorphCallbacks(callbacks) + }); + } + + /** + * Morph the child elements of the currentElement based on the child elements of + * the newElement. Morphing children may dispatch turbo:before-morph-element, + * turbo:before-morph-attribute, and turbo:morph-element events. + * + * @param currentElement Element destination of morphing children changes + * @param newElement Element source of morphing children changes + */ + function morphChildren(currentElement, newElement, options = {}) { + morphElements(currentElement, newElement.childNodes, { + ...options, + morphStyle: "innerHTML" + }); + } + + function shouldRefreshFrameWithMorphing(currentFrame, newFrame) { + return currentFrame instanceof FrameElement && + currentFrame.shouldReloadWithMorph && (!newFrame || areFramesCompatibleForRefreshing(currentFrame, newFrame)) && + !currentFrame.closest("[data-turbo-permanent]") + } + + function areFramesCompatibleForRefreshing(currentFrame, newFrame) { + // newFrame cannot yet be an instance of FrameElement because custom + // elements don't get initialized until they're attached to the DOM, so + // test its Element#nodeName instead + return newFrame instanceof Element && newFrame.nodeName === "TURBO-FRAME" && currentFrame.id === newFrame.id && + (!newFrame.getAttribute("src") || urlsAreEqual(currentFrame.src, newFrame.getAttribute("src"))) + } + + function closestFrameReloadableWithMorphing(node) { + return node.parentElement.closest("turbo-frame[src][refresh=morph]") + } + + 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, { + callbacks: { + beforeNodeMorphed: (node, newNode) => { + if ( + shouldRefreshFrameWithMorphing(node, newNode) && + closestFrameReloadableWithMorphing(node) === currentElement + ) { + node.reload(); + return false + } + return true + } + } + }); + } + + async preservingPermanentElements(callback) { + return await callback() + } + } + class ProgressBar { static animationDuration = 300 /*ms*/ @@ -2056,8 +3626,9 @@ Copyright © 2024 37signals LLC const element = document.createElement("style"); element.type = "text/css"; element.textContent = ProgressBar.defaultCSS; - if (this.cspNonce) { - element.nonce = this.cspNonce; + const cspNonce = getCspNonce(); + if (cspNonce) { + element.nonce = cspNonce; } return element } @@ -2067,10 +3638,6 @@ Copyright © 2024 37signals LLC element.className = "turbo-progress-bar"; return element } - - get cspNonce() { - return getMetaContent("csp-nonce") - } } class HeadSnapshot extends Snapshot { @@ -2221,6 +3788,10 @@ Copyright © 2024 37signals LLC clonedPasswordInput.value = ""; } + for (const clonedNoscriptElement of clonedElement.querySelectorAll("noscript")) { + clonedNoscriptElement.remove(); + } + return new PageSnapshot(this.documentElement, clonedElement, this.headSnapshot) } @@ -2228,6 +3799,10 @@ Copyright © 2024 37signals LLC return this.documentElement.getAttribute("lang") } + get dir() { + return this.documentElement.getAttribute("dir") + } + get headElement() { return this.headSnapshot.element } @@ -2254,15 +3829,16 @@ Copyright © 2024 37signals LLC } get prefersViewTransitions() { - return this.headSnapshot.getMetaValue("view-transition") === "same-origin" + const viewTransitionEnabled = this.getSetting("view-transition") === "true" || this.headSnapshot.getMetaValue("view-transition") === "same-origin"; + return viewTransitionEnabled && !window.matchMedia("(prefers-reduced-motion: reduce)").matches } - get shouldMorphPage() { - return this.getSetting("refresh-method") === "morph" + get refreshMethod() { + return this.getSetting("refresh-method") } - get shouldPreserveScrollPosition() { - return this.getSetting("refresh-scroll") === "preserve" + get refreshScroll() { + return this.getSetting("refresh-scroll") } // Private @@ -2301,7 +3877,8 @@ Copyright © 2024 37signals LLC willRender: true, updateHistory: true, shouldCacheSnapshot: true, - acceptsStreamResponse: false + acceptsStreamResponse: false, + refresh: {} }; const TimingMetric = { @@ -2361,7 +3938,8 @@ Copyright © 2024 37signals LLC updateHistory, shouldCacheSnapshot, acceptsStreamResponse, - direction + direction, + refresh } = { ...defaultOptions, ...options @@ -2372,7 +3950,6 @@ Copyright © 2024 37signals LLC 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; @@ -2381,6 +3958,7 @@ Copyright © 2024 37signals LLC this.shouldCacheSnapshot = shouldCacheSnapshot; this.acceptsStreamResponse = acceptsStreamResponse; this.direction = direction || Direction[action]; + this.refresh = refresh; } get adapter() { @@ -2399,10 +3977,6 @@ Copyright © 2024 37signals LLC return this.history.getRestorationDataForIdentifier(this.restorationIdentifier) } - get silent() { - return this.isSamePage - } - start() { if (this.state == VisitState.initialized) { this.recordTimingMetric(TimingMetric.visitStart); @@ -2539,7 +4113,7 @@ Copyright © 2024 37signals LLC const isPreview = this.shouldIssueRequest(); this.render(async () => { this.cacheSnapshot(); - if (this.isSamePage || this.isPageRefresh) { + if (this.isPageRefresh) { this.adapter.visitRendered(this); } else { if (this.view.renderPromise) await this.view.renderPromise; @@ -2567,17 +4141,6 @@ Copyright © 2024 37signals LLC } } - goToSamePageAnchor() { - if (this.isSamePage) { - this.render(async () => { - this.cacheSnapshot(); - this.performScroll(); - this.changeHistory(); - this.adapter.visitRendered(this); - }); - } - } - // Fetch request delegate prepareRequest(request) { @@ -2639,9 +4202,6 @@ Copyright © 2024 37signals LLC } else { this.scrollToAnchor() || this.view.scrollToTop(); } - if (this.isSamePage) { - this.delegate.visitScrolledToSamePageLocation(this.view.lastRenderedLocation, this.location); - } this.scrolled = true; } @@ -2675,24 +4235,12 @@ Copyright © 2024 37signals LLC // 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") { + if (this.action == "restore") { return !this.hasCachedSnapshot() } else { return this.willRender @@ -2708,7 +4256,10 @@ Copyright © 2024 37signals LLC async render(callback) { this.cancelRender(); - this.frame = await nextRepaint(); + await new Promise((resolve) => { + this.frame = + document.visibilityState === "hidden" ? setTimeout(() => resolve(), 0) : requestAnimationFrame(() => resolve()); + }); await callback(); delete this.frame; } @@ -2749,9 +4300,10 @@ Copyright © 2024 37signals LLC visitStarted(visit) { this.location = visit.location; + this.redirectedToLocation = null; + visit.loadCachedSnapshot(); visit.issueRequest(); - visit.goToSamePageAnchor(); } visitRequestStarted(visit) { @@ -2765,6 +4317,10 @@ Copyright © 2024 37signals LLC visitRequestCompleted(visit) { visit.loadResponse(); + + if (visit.response.redirected) { + this.redirectedToLocation = visit.redirectedToLocation; + } } visitRequestFailedWithStatusCode(visit, statusCode) { @@ -2801,6 +4357,12 @@ Copyright © 2024 37signals LLC visitRendered(_visit) {} + // Link prefetching + + linkPrefetchingIsEnabledForLocation(location) { + return true + } + // Form Submission Delegate formSubmissionStarted(_formSubmission) { @@ -2848,7 +4410,7 @@ Copyright © 2024 37signals LLC reload(reason) { dispatch("turbo:reload", { detail: reason }); - window.location.href = this.location?.toString() || window.location.href; + window.location.href = (this.redirectedToLocation || this.location)?.toString() || window.location.href; } get navigator() { @@ -2858,7 +4420,6 @@ Copyright © 2024 37signals LLC class CacheObserver { selector = "[data-turbo-temporary]" - deprecatedSelector = "[data-turbo-cache=false]" started = false @@ -2883,19 +4444,7 @@ Copyright © 2024 37signals LLC } 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] + return [...document.querySelectorAll(this.selector)] } } @@ -2985,7 +4534,6 @@ Copyright © 2024 37signals LLC restorationIdentifier = uuid() restorationData = {} started = false - pageLoaded = false currentIndex = 0 constructor(delegate) { @@ -2995,7 +4543,6 @@ Copyright © 2024 37signals LLC 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)); @@ -3005,7 +4552,6 @@ Copyright © 2024 37signals LLC stop() { if (this.started) { removeEventListener("popstate", this.onPopState, false); - removeEventListener("load", this.onPageLoad, false); this.started = false; } } @@ -3061,34 +4607,20 @@ Copyright © 2024 37signals LLC // 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; - } + const { turbo } = event.state || {}; + this.location = new URL(window.location.href); + + if (turbo) { + const { restorationIdentifier, restorationIndex } = turbo; + this.restorationIdentifier = restorationIdentifier; + const direction = restorationIndex > this.currentIndex ? "forward" : "back"; + this.delegate.historyPoppedToLocationWithRestorationIdentifierAndDirection(this.location, restorationIdentifier, direction); + this.currentIndex = restorationIndex; + } else { + this.currentIndex++; + this.delegate.historyPoppedWithEmptyState(this.location); } } - - 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 { @@ -3161,7 +4693,9 @@ Copyright © 2024 37signals LLC target ); - prefetchCache.setLater(location.toString(), fetchRequest, this.#cacheTtl); + fetchRequest.fetchOptions.priority = "low"; + + prefetchCache.putLater(location, fetchRequest, this.#cacheTtl); } } } @@ -3177,7 +4711,7 @@ Copyright © 2024 37signals LLC #tryToUsePrefetchedRequest = (event) => { if (event.target.tagName !== "FORM" && event.detail.fetchOptions.method === "GET") { - const cached = prefetchCache.get(event.detail.url.toString()); + const cached = prefetchCache.get(event.detail.url); if (cached) { // User clicked link, use cache response @@ -3367,7 +4901,7 @@ Copyright © 2024 37signals LLC } else { await this.view.renderPage(snapshot, false, true, this.currentVisit); } - if(!snapshot.shouldPreserveScrollPosition) { + if (snapshot.refreshScroll !== "preserve") { this.view.scrollToTop(); } this.view.clearSnapshotCache(); @@ -3385,6 +4919,17 @@ Copyright © 2024 37signals LLC } } + // Link prefetching + + linkPrefetchingIsEnabledForLocation(location) { + // Not all adapters implement linkPrefetchingIsEnabledForLocation + if (typeof this.adapter.linkPrefetchingIsEnabledForLocation === "function") { + return this.adapter.linkPrefetchingIsEnabledForLocation(location) + } + + return true + } + // Visit delegate visitStarted(visit) { @@ -3396,20 +4941,10 @@ Copyright © 2024 37signals LLC delete this.currentVisit; } + // Same-page links are no longer handled with a Visit. + // This method is still needed for Turbo Native adapters. 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); + return false } // Visits @@ -3743,914 +5278,6 @@ Copyright © 2024 37signals LLC } } - // base IIFE to define idiomorph - var Idiomorph = (function () { - - //============================================================================= - // AND NOW IT BEGINS... - //============================================================================= - let EMPTY_SET = new Set(); - - // default configuration values, updatable by users now - let defaults = { - morphStyle: "outerHTML", - callbacks : { - beforeNodeAdded: noOp, - afterNodeAdded: noOp, - beforeNodeMorphed: noOp, - afterNodeMorphed: noOp, - beforeNodeRemoved: noOp, - afterNodeRemoved: noOp, - beforeAttributeUpdated: noOp, - - }, - head: { - style: 'merge', - shouldPreserve: function (elt) { - return elt.getAttribute("im-preserve") === "true"; - }, - shouldReAppend: function (elt) { - return elt.getAttribute("im-re-append") === "true"; - }, - shouldRemove: noOp, - afterHeadMorphed: noOp, - } - }; - - //============================================================================= - // Core Morphing Algorithm - morph, morphNormalizedContent, morphOldNodeTo, morphChildren - //============================================================================= - function morph(oldNode, newContent, config = {}) { - - if (oldNode instanceof Document) { - oldNode = oldNode.documentElement; - } - - if (typeof newContent === 'string') { - newContent = parseContent(newContent); - } - - let normalizedContent = normalizeContent(newContent); - - let ctx = createMorphContext(oldNode, normalizedContent, config); - - return morphNormalizedContent(oldNode, normalizedContent, ctx); - } - - function morphNormalizedContent(oldNode, normalizedNewContent, ctx) { - if (ctx.head.block) { - let oldHead = oldNode.querySelector('head'); - let newHead = normalizedNewContent.querySelector('head'); - if (oldHead && newHead) { - let promises = handleHeadElement(newHead, oldHead, ctx); - // when head promises resolve, call morph again, ignoring the head tag - Promise.all(promises).then(function () { - morphNormalizedContent(oldNode, normalizedNewContent, Object.assign(ctx, { - head: { - block: false, - ignore: true - } - })); - }); - return; - } - } - - if (ctx.morphStyle === "innerHTML") { - - // innerHTML, so we are only updating the children - morphChildren(normalizedNewContent, oldNode, ctx); - return oldNode.children; - - } else if (ctx.morphStyle === "outerHTML" || ctx.morphStyle == null) { - // otherwise find the best element match in the new content, morph that, and merge its siblings - // into either side of the best match - let bestMatch = findBestNodeMatch(normalizedNewContent, oldNode, ctx); - - // stash the siblings that will need to be inserted on either side of the best match - let previousSibling = bestMatch?.previousSibling; - let nextSibling = bestMatch?.nextSibling; - - // morph it - let morphedNode = morphOldNodeTo(oldNode, bestMatch, ctx); - - if (bestMatch) { - // if there was a best match, merge the siblings in too and return the - // whole bunch - return insertSiblings(previousSibling, morphedNode, nextSibling); - } else { - // otherwise nothing was added to the DOM - return [] - } - } else { - throw "Do not understand how to morph style " + ctx.morphStyle; - } - } - - - /** - * @param possibleActiveElement - * @param ctx - * @returns {boolean} - */ - function ignoreValueOfActiveElement(possibleActiveElement, ctx) { - return ctx.ignoreActiveValue && possibleActiveElement === document.activeElement && possibleActiveElement !== document.body; - } - - /** - * @param oldNode root node to merge content into - * @param newContent new content to merge - * @param ctx the merge context - * @returns {Element} the element that ended up in the DOM - */ - function morphOldNodeTo(oldNode, newContent, ctx) { - if (ctx.ignoreActive && oldNode === document.activeElement) ; else if (newContent == null) { - if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode; - - oldNode.remove(); - ctx.callbacks.afterNodeRemoved(oldNode); - return null; - } else if (!isSoftMatch(oldNode, newContent)) { - if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode; - if (ctx.callbacks.beforeNodeAdded(newContent) === false) return oldNode; - - oldNode.parentElement.replaceChild(newContent, oldNode); - ctx.callbacks.afterNodeAdded(newContent); - ctx.callbacks.afterNodeRemoved(oldNode); - return newContent; - } else { - if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) return oldNode; - - if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) ; else if (oldNode instanceof HTMLHeadElement && ctx.head.style !== "morph") { - handleHeadElement(newContent, oldNode, ctx); - } else { - syncNodeFrom(newContent, oldNode, ctx); - if (!ignoreValueOfActiveElement(oldNode, ctx)) { - morphChildren(newContent, oldNode, ctx); - } - } - ctx.callbacks.afterNodeMorphed(oldNode, newContent); - return oldNode; - } - } - - /** - * This is the core algorithm for matching up children. The idea is to use id sets to try to match up - * nodes as faithfully as possible. We greedily match, which allows us to keep the algorithm fast, but - * by using id sets, we are able to better match up with content deeper in the DOM. - * - * Basic algorithm is, for each node in the new content: - * - * - if we have reached the end of the old parent, append the new content - * - if the new content has an id set match with the current insertion point, morph - * - search for an id set match - * - if id set match found, morph - * - otherwise search for a "soft" match - * - if a soft match is found, morph - * - otherwise, prepend the new node before the current insertion point - * - * The two search algorithms terminate if competing node matches appear to outweigh what can be achieved - * with the current node. See findIdSetMatch() and findSoftMatch() for details. - * - * @param {Element} newParent the parent element of the new content - * @param {Element } oldParent the old content that we are merging the new content into - * @param ctx the merge context - */ - function morphChildren(newParent, oldParent, ctx) { - - let nextNewChild = newParent.firstChild; - let insertionPoint = oldParent.firstChild; - let newChild; - - // run through all the new content - while (nextNewChild) { - - newChild = nextNewChild; - nextNewChild = newChild.nextSibling; - - // if we are at the end of the exiting parent's children, just append - if (insertionPoint == null) { - if (ctx.callbacks.beforeNodeAdded(newChild) === false) return; - - oldParent.appendChild(newChild); - ctx.callbacks.afterNodeAdded(newChild); - removeIdsFromConsideration(ctx, newChild); - continue; - } - - // if the current node has an id set match then morph - if (isIdSetMatch(newChild, insertionPoint, ctx)) { - morphOldNodeTo(insertionPoint, newChild, ctx); - insertionPoint = insertionPoint.nextSibling; - removeIdsFromConsideration(ctx, newChild); - continue; - } - - // otherwise search forward in the existing old children for an id set match - let idSetMatch = findIdSetMatch(newParent, oldParent, newChild, insertionPoint, ctx); - - // if we found a potential match, remove the nodes until that point and morph - if (idSetMatch) { - insertionPoint = removeNodesBetween(insertionPoint, idSetMatch, ctx); - morphOldNodeTo(idSetMatch, newChild, ctx); - removeIdsFromConsideration(ctx, newChild); - continue; - } - - // no id set match found, so scan forward for a soft match for the current node - let softMatch = findSoftMatch(newParent, oldParent, newChild, insertionPoint, ctx); - - // if we found a soft match for the current node, morph - if (softMatch) { - insertionPoint = removeNodesBetween(insertionPoint, softMatch, ctx); - morphOldNodeTo(softMatch, newChild, ctx); - removeIdsFromConsideration(ctx, newChild); - continue; - } - - // abandon all hope of morphing, just insert the new child before the insertion point - // and move on - if (ctx.callbacks.beforeNodeAdded(newChild) === false) return; - - oldParent.insertBefore(newChild, insertionPoint); - ctx.callbacks.afterNodeAdded(newChild); - removeIdsFromConsideration(ctx, newChild); - } - - // remove any remaining old nodes that didn't match up with new content - while (insertionPoint !== null) { - - let tempNode = insertionPoint; - insertionPoint = insertionPoint.nextSibling; - removeNode(tempNode, ctx); - } - } - - //============================================================================= - // Attribute Syncing Code - //============================================================================= - - /** - * @param attr {String} the attribute to be mutated - * @param to {Element} the element that is going to be updated - * @param updateType {("update"|"remove")} - * @param ctx the merge context - * @returns {boolean} true if the attribute should be ignored, false otherwise - */ - function ignoreAttribute(attr, to, updateType, ctx) { - if(attr === 'value' && ctx.ignoreActiveValue && to === document.activeElement){ - return true; - } - return ctx.callbacks.beforeAttributeUpdated(attr, to, updateType) === false; - } - - /** - * syncs a given node with another node, copying over all attributes and - * inner element state from the 'from' node to the 'to' node - * - * @param {Element} from the element to copy attributes & state from - * @param {Element} to the element to copy attributes & state to - * @param ctx the merge context - */ - function syncNodeFrom(from, to, ctx) { - let type = from.nodeType; - - // if is an element type, sync the attributes from the - // new node into the new node - if (type === 1 /* element type */) { - const fromAttributes = from.attributes; - const toAttributes = to.attributes; - for (const fromAttribute of fromAttributes) { - if (ignoreAttribute(fromAttribute.name, to, 'update', ctx)) { - continue; - } - if (to.getAttribute(fromAttribute.name) !== fromAttribute.value) { - to.setAttribute(fromAttribute.name, fromAttribute.value); - } - } - // iterate backwards to avoid skipping over items when a delete occurs - for (let i = toAttributes.length - 1; 0 <= i; i--) { - const toAttribute = toAttributes[i]; - if (ignoreAttribute(toAttribute.name, to, 'remove', ctx)) { - continue; - } - if (!from.hasAttribute(toAttribute.name)) { - to.removeAttribute(toAttribute.name); - } - } - } - - // sync text nodes - if (type === 8 /* comment */ || type === 3 /* text */) { - if (to.nodeValue !== from.nodeValue) { - to.nodeValue = from.nodeValue; - } - } - - if (!ignoreValueOfActiveElement(to, ctx)) { - // sync input values - syncInputValue(from, to, ctx); - } - } - - /** - * @param from {Element} element to sync the value from - * @param to {Element} element to sync the value to - * @param attributeName {String} the attribute name - * @param ctx the merge context - */ - function syncBooleanAttribute(from, to, attributeName, ctx) { - if (from[attributeName] !== to[attributeName]) { - let ignoreUpdate = ignoreAttribute(attributeName, to, 'update', ctx); - if (!ignoreUpdate) { - to[attributeName] = from[attributeName]; - } - if (from[attributeName]) { - if (!ignoreUpdate) { - to.setAttribute(attributeName, from[attributeName]); - } - } else { - if (!ignoreAttribute(attributeName, to, 'remove', ctx)) { - to.removeAttribute(attributeName); - } - } - } - } - - /** - * NB: many bothans died to bring us information: - * - * https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js - * https://github.com/choojs/nanomorph/blob/master/lib/morph.jsL113 - * - * @param from {Element} the element to sync the input value from - * @param to {Element} the element to sync the input value to - * @param ctx the merge context - */ - function syncInputValue(from, to, ctx) { - if (from instanceof HTMLInputElement && - to instanceof HTMLInputElement && - from.type !== 'file') { - - let fromValue = from.value; - let toValue = to.value; - - // sync boolean attributes - syncBooleanAttribute(from, to, 'checked', ctx); - syncBooleanAttribute(from, to, 'disabled', ctx); - - if (!from.hasAttribute('value')) { - if (!ignoreAttribute('value', to, 'remove', ctx)) { - to.value = ''; - to.removeAttribute('value'); - } - } else if (fromValue !== toValue) { - if (!ignoreAttribute('value', to, 'update', ctx)) { - to.setAttribute('value', fromValue); - to.value = fromValue; - } - } - } else if (from instanceof HTMLOptionElement) { - syncBooleanAttribute(from, to, 'selected', ctx); - } else if (from instanceof HTMLTextAreaElement && to instanceof HTMLTextAreaElement) { - let fromValue = from.value; - let toValue = to.value; - if (ignoreAttribute('value', to, 'update', ctx)) { - return; - } - if (fromValue !== toValue) { - to.value = fromValue; - } - if (to.firstChild && to.firstChild.nodeValue !== fromValue) { - to.firstChild.nodeValue = fromValue; - } - } - } - - //============================================================================= - // the HEAD tag can be handled specially, either w/ a 'merge' or 'append' style - //============================================================================= - function handleHeadElement(newHeadTag, currentHead, ctx) { - - let added = []; - let removed = []; - let preserved = []; - let nodesToAppend = []; - - let headMergeStyle = ctx.head.style; - - // put all new head elements into a Map, by their outerHTML - let srcToNewHeadNodes = new Map(); - for (const newHeadChild of newHeadTag.children) { - srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild); - } - - // for each elt in the current head - for (const currentHeadElt of currentHead.children) { - - // If the current head element is in the map - let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML); - let isReAppended = ctx.head.shouldReAppend(currentHeadElt); - let isPreserved = ctx.head.shouldPreserve(currentHeadElt); - if (inNewContent || isPreserved) { - if (isReAppended) { - // remove the current version and let the new version replace it and re-execute - removed.push(currentHeadElt); - } else { - // this element already exists and should not be re-appended, so remove it from - // the new content map, preserving it in the DOM - srcToNewHeadNodes.delete(currentHeadElt.outerHTML); - preserved.push(currentHeadElt); - } - } else { - if (headMergeStyle === "append") { - // we are appending and this existing element is not new content - // so if and only if it is marked for re-append do we do anything - if (isReAppended) { - removed.push(currentHeadElt); - nodesToAppend.push(currentHeadElt); - } - } else { - // if this is a merge, we remove this content since it is not in the new head - if (ctx.head.shouldRemove(currentHeadElt) !== false) { - removed.push(currentHeadElt); - } - } - } - } - - // Push the remaining new head elements in the Map into the - // nodes to append to the head tag - nodesToAppend.push(...srcToNewHeadNodes.values()); - - let promises = []; - for (const newNode of nodesToAppend) { - let newElt = document.createRange().createContextualFragment(newNode.outerHTML).firstChild; - if (ctx.callbacks.beforeNodeAdded(newElt) !== false) { - if (newElt.href || newElt.src) { - let resolve = null; - let promise = new Promise(function (_resolve) { - resolve = _resolve; - }); - newElt.addEventListener('load', function () { - resolve(); - }); - promises.push(promise); - } - currentHead.appendChild(newElt); - ctx.callbacks.afterNodeAdded(newElt); - added.push(newElt); - } - } - - // remove all removed elements, after we have appended the new elements to avoid - // additional network requests for things like style sheets - for (const removedElement of removed) { - if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) { - currentHead.removeChild(removedElement); - ctx.callbacks.afterNodeRemoved(removedElement); - } - } - - ctx.head.afterHeadMorphed(currentHead, {added: added, kept: preserved, removed: removed}); - return promises; - } - - function noOp() { - } - - /* - Deep merges the config object and the Idiomoroph.defaults object to - produce a final configuration object - */ - function mergeDefaults(config) { - let finalConfig = {}; - // copy top level stuff into final config - Object.assign(finalConfig, defaults); - Object.assign(finalConfig, config); - - // copy callbacks into final config (do this to deep merge the callbacks) - finalConfig.callbacks = {}; - Object.assign(finalConfig.callbacks, defaults.callbacks); - Object.assign(finalConfig.callbacks, config.callbacks); - - // copy head config into final config (do this to deep merge the head) - finalConfig.head = {}; - Object.assign(finalConfig.head, defaults.head); - Object.assign(finalConfig.head, config.head); - return finalConfig; - } - - function createMorphContext(oldNode, newContent, config) { - config = mergeDefaults(config); - return { - target: oldNode, - newContent: newContent, - config: config, - morphStyle: config.morphStyle, - ignoreActive: config.ignoreActive, - ignoreActiveValue: config.ignoreActiveValue, - idMap: createIdMap(oldNode, newContent), - deadIds: new Set(), - callbacks: config.callbacks, - head: config.head - } - } - - function isIdSetMatch(node1, node2, ctx) { - if (node1 == null || node2 == null) { - return false; - } - if (node1.nodeType === node2.nodeType && node1.tagName === node2.tagName) { - if (node1.id !== "" && node1.id === node2.id) { - return true; - } else { - return getIdIntersectionCount(ctx, node1, node2) > 0; - } - } - return false; - } - - function isSoftMatch(node1, node2) { - if (node1 == null || node2 == null) { - return false; - } - return node1.nodeType === node2.nodeType && node1.tagName === node2.tagName - } - - function removeNodesBetween(startInclusive, endExclusive, ctx) { - while (startInclusive !== endExclusive) { - let tempNode = startInclusive; - startInclusive = startInclusive.nextSibling; - removeNode(tempNode, ctx); - } - removeIdsFromConsideration(ctx, endExclusive); - return endExclusive.nextSibling; - } - - //============================================================================= - // Scans forward from the insertionPoint in the old parent looking for a potential id match - // for the newChild. We stop if we find a potential id match for the new child OR - // if the number of potential id matches we are discarding is greater than the - // potential id matches for the new child - //============================================================================= - function findIdSetMatch(newContent, oldParent, newChild, insertionPoint, ctx) { - - // max id matches we are willing to discard in our search - let newChildPotentialIdCount = getIdIntersectionCount(ctx, newChild, oldParent); - - let potentialMatch = null; - - // only search forward if there is a possibility of an id match - if (newChildPotentialIdCount > 0) { - let potentialMatch = insertionPoint; - // if there is a possibility of an id match, scan forward - // keep track of the potential id match count we are discarding (the - // newChildPotentialIdCount must be greater than this to make it likely - // worth it) - let otherMatchCount = 0; - while (potentialMatch != null) { - - // If we have an id match, return the current potential match - if (isIdSetMatch(newChild, potentialMatch, ctx)) { - return potentialMatch; - } - - // computer the other potential matches of this new content - otherMatchCount += getIdIntersectionCount(ctx, potentialMatch, newContent); - if (otherMatchCount > newChildPotentialIdCount) { - // if we have more potential id matches in _other_ content, we - // do not have a good candidate for an id match, so return null - return null; - } - - // advanced to the next old content child - potentialMatch = potentialMatch.nextSibling; - } - } - return potentialMatch; - } - - //============================================================================= - // Scans forward from the insertionPoint in the old parent looking for a potential soft match - // for the newChild. We stop if we find a potential soft match for the new child OR - // if we find a potential id match in the old parents children OR if we find two - // potential soft matches for the next two pieces of new content - //============================================================================= - function findSoftMatch(newContent, oldParent, newChild, insertionPoint, ctx) { - - let potentialSoftMatch = insertionPoint; - let nextSibling = newChild.nextSibling; - let siblingSoftMatchCount = 0; - - while (potentialSoftMatch != null) { - - if (getIdIntersectionCount(ctx, potentialSoftMatch, newContent) > 0) { - // the current potential soft match has a potential id set match with the remaining new - // content so bail out of looking - return null; - } - - // if we have a soft match with the current node, return it - if (isSoftMatch(newChild, potentialSoftMatch)) { - return potentialSoftMatch; - } - - if (isSoftMatch(nextSibling, potentialSoftMatch)) { - // the next new node has a soft match with this node, so - // increment the count of future soft matches - siblingSoftMatchCount++; - nextSibling = nextSibling.nextSibling; - - // If there are two future soft matches, bail to allow the siblings to soft match - // so that we don't consume future soft matches for the sake of the current node - if (siblingSoftMatchCount >= 2) { - return null; - } - } - - // advanced to the next old content child - potentialSoftMatch = potentialSoftMatch.nextSibling; - } - - return potentialSoftMatch; - } - - function parseContent(newContent) { - let parser = new DOMParser(); - - // remove svgs to avoid false-positive matches on head, etc. - let contentWithSvgsRemoved = newContent.replace(/]*>|>)([\s\S]*?)<\/svg>/gim, ''); - - // if the newContent contains a html, head or body tag, we can simply parse it w/o wrapping - if (contentWithSvgsRemoved.match(/<\/html>/) || contentWithSvgsRemoved.match(/<\/head>/) || contentWithSvgsRemoved.match(/<\/body>/)) { - let content = parser.parseFromString(newContent, "text/html"); - // if it is a full HTML document, return the document itself as the parent container - if (contentWithSvgsRemoved.match(/<\/html>/)) { - content.generatedByIdiomorph = true; - return content; - } else { - // otherwise return the html element as the parent container - let htmlElement = content.firstChild; - if (htmlElement) { - htmlElement.generatedByIdiomorph = true; - return htmlElement; - } else { - return null; - } - } - } else { - // if it is partial HTML, wrap it in a template tag to provide a parent element and also to help - // deal with touchy tags like tr, tbody, etc. - let responseDoc = parser.parseFromString("", "text/html"); - let content = responseDoc.body.querySelector('template').content; - content.generatedByIdiomorph = true; - return content - } - } - - function normalizeContent(newContent) { - if (newContent == null) { - // noinspection UnnecessaryLocalVariableJS - const dummyParent = document.createElement('div'); - return dummyParent; - } else if (newContent.generatedByIdiomorph) { - // the template tag created by idiomorph parsing can serve as a dummy parent - return newContent; - } else if (newContent instanceof Node) { - // a single node is added as a child to a dummy parent - const dummyParent = document.createElement('div'); - dummyParent.append(newContent); - return dummyParent; - } else { - // all nodes in the array or HTMLElement collection are consolidated under - // a single dummy parent element - const dummyParent = document.createElement('div'); - for (const elt of [...newContent]) { - dummyParent.append(elt); - } - return dummyParent; - } - } - - function insertSiblings(previousSibling, morphedNode, nextSibling) { - let stack = []; - let added = []; - while (previousSibling != null) { - stack.push(previousSibling); - previousSibling = previousSibling.previousSibling; - } - while (stack.length > 0) { - let node = stack.pop(); - added.push(node); // push added preceding siblings on in order and insert - morphedNode.parentElement.insertBefore(node, morphedNode); - } - added.push(morphedNode); - while (nextSibling != null) { - stack.push(nextSibling); - added.push(nextSibling); // here we are going in order, so push on as we scan, rather than add - nextSibling = nextSibling.nextSibling; - } - while (stack.length > 0) { - morphedNode.parentElement.insertBefore(stack.pop(), morphedNode.nextSibling); - } - return added; - } - - function findBestNodeMatch(newContent, oldNode, ctx) { - let currentElement; - currentElement = newContent.firstChild; - let bestElement = currentElement; - let score = 0; - while (currentElement) { - let newScore = scoreElement(currentElement, oldNode, ctx); - if (newScore > score) { - bestElement = currentElement; - score = newScore; - } - currentElement = currentElement.nextSibling; - } - return bestElement; - } - - function scoreElement(node1, node2, ctx) { - if (isSoftMatch(node1, node2)) { - return .5 + getIdIntersectionCount(ctx, node1, node2); - } - return 0; - } - - function removeNode(tempNode, ctx) { - removeIdsFromConsideration(ctx, tempNode); - if (ctx.callbacks.beforeNodeRemoved(tempNode) === false) return; - - tempNode.remove(); - ctx.callbacks.afterNodeRemoved(tempNode); - } - - //============================================================================= - // ID Set Functions - //============================================================================= - - function isIdInConsideration(ctx, id) { - return !ctx.deadIds.has(id); - } - - function idIsWithinNode(ctx, id, targetNode) { - let idSet = ctx.idMap.get(targetNode) || EMPTY_SET; - return idSet.has(id); - } - - function removeIdsFromConsideration(ctx, node) { - let idSet = ctx.idMap.get(node) || EMPTY_SET; - for (const id of idSet) { - ctx.deadIds.add(id); - } - } - - function getIdIntersectionCount(ctx, node1, node2) { - let sourceSet = ctx.idMap.get(node1) || EMPTY_SET; - let matchCount = 0; - for (const id of sourceSet) { - // a potential match is an id in the source and potentialIdsSet, but - // that has not already been merged into the DOM - if (isIdInConsideration(ctx, id) && idIsWithinNode(ctx, id, node2)) { - ++matchCount; - } - } - return matchCount; - } - - /** - * A bottom up algorithm that finds all elements with ids inside of the node - * argument and populates id sets for those nodes and all their parents, generating - * a set of ids contained within all nodes for the entire hierarchy in the DOM - * - * @param node {Element} - * @param {Map>} idMap - */ - function populateIdMapForNode(node, idMap) { - let nodeParent = node.parentElement; - // find all elements with an id property - let idElements = node.querySelectorAll('[id]'); - for (const elt of idElements) { - let current = elt; - // walk up the parent hierarchy of that element, adding the id - // of element to the parent's id set - while (current !== nodeParent && current != null) { - let idSet = idMap.get(current); - // if the id set doesn't exist, create it and insert it in the map - if (idSet == null) { - idSet = new Set(); - idMap.set(current, idSet); - } - idSet.add(elt.id); - current = current.parentElement; - } - } - } - - /** - * This function computes a map of nodes to all ids contained within that node (inclusive of the - * node). This map can be used to ask if two nodes have intersecting sets of ids, which allows - * for a looser definition of "matching" than tradition id matching, and allows child nodes - * to contribute to a parent nodes matching. - * - * @param {Element} oldContent the old content that will be morphed - * @param {Element} newContent the new content to morph to - * @returns {Map>} a map of nodes to id sets for the - */ - function createIdMap(oldContent, newContent) { - let idMap = new Map(); - populateIdMapForNode(oldContent, idMap); - populateIdMapForNode(newContent, idMap); - return idMap; - } - - //============================================================================= - // This is what ends up becoming the Idiomorph global object - //============================================================================= - return { - morph, - defaults - } - })(); - - function morphElements(currentElement, newElement, { callbacks, ...options } = {}) { - Idiomorph.morph(currentElement, newElement, { - ...options, - callbacks: new DefaultIdiomorphCallbacks(callbacks) - }); - } - - function morphChildren(currentElement, newElement) { - morphElements(currentElement, newElement.children, { - morphStyle: "innerHTML" - }); - } - - class DefaultIdiomorphCallbacks { - #beforeNodeMorphed - - constructor({ beforeNodeMorphed } = {}) { - this.#beforeNodeMorphed = beforeNodeMorphed || (() => true); - } - - beforeNodeAdded = (node) => { - return !(node.id && node.hasAttribute("data-turbo-permanent") && document.getElementById(node.id)) - } - - beforeNodeMorphed = (currentElement, newElement) => { - if (currentElement instanceof Element) { - if (!currentElement.hasAttribute("data-turbo-permanent") && this.#beforeNodeMorphed(currentElement, newElement)) { - const event = dispatch("turbo:before-morph-element", { - cancelable: true, - target: currentElement, - detail: { currentElement, newElement } - }); - - return !event.defaultPrevented - } else { - return false - } - } - } - - beforeAttributeUpdated = (attributeName, target, mutationType) => { - const event = dispatch("turbo:before-morph-attribute", { - cancelable: true, - target, - detail: { attributeName, mutationType } - }); - - return !event.defaultPrevented - } - - beforeNodeRemoved = (node) => { - return this.beforeNodeMorphed(node) - } - - afterNodeMorphed = (currentElement, newElement) => { - if (currentElement instanceof Element) { - dispatch("turbo:morph-element", { - target: currentElement, - detail: { currentElement, newElement } - }); - } - } - } - - class MorphingFrameRenderer extends FrameRenderer { - static renderElement(currentElement, newElement) { - dispatch("turbo:before-frame-morph", { - target: currentElement, - detail: { currentElement, newElement } - }); - - morphChildren(currentElement, newElement); - } - } - class PageRenderer extends Renderer { static renderElement(currentElement, newElement) { if (document.body && newElement instanceof HTMLBodyElement) { @@ -4710,13 +5337,18 @@ Copyright © 2024 37signals LLC #setLanguage() { const { documentElement } = this.currentSnapshot; - const { lang } = this.newSnapshot; + const { dir, lang } = this.newSnapshot; if (lang) { documentElement.setAttribute("lang", lang); } else { documentElement.removeAttribute("lang"); } + if (dir) { + documentElement.setAttribute("dir", dir); + } else { + documentElement.removeAttribute("dir"); + } } async mergeHead() { @@ -4818,9 +5450,16 @@ Copyright © 2024 37signals LLC activateNewBody() { document.adoptNode(this.newElement); + this.removeNoscriptElements(); this.activateNewBodyScriptElements(); } + removeNoscriptElements() { + for (const noscriptElement of this.newElement.querySelectorAll("noscript")) { + noscriptElement.remove(); + } + } + activateNewBodyScriptElements() { for (const inertScriptElement of this.newBodyScriptElements) { const activatedScriptElement = activateScriptElement(inertScriptElement); @@ -4867,14 +5506,19 @@ Copyright © 2024 37signals LLC static renderElement(currentElement, newElement) { morphElements(currentElement, newElement, { callbacks: { - beforeNodeMorphed: element => !canRefreshFrame(element) + beforeNodeMorphed: (node, newNode) => { + if ( + shouldRefreshFrameWithMorphing(node, newNode) && + !closestFrameReloadableWithMorphing(node) + ) { + node.reload(); + return false + } + return true + } } }); - for (const frame of currentElement.querySelectorAll("turbo-frame")) { - if (canRefreshFrame(frame)) refreshFrame(frame); - } - dispatch("turbo:morph", { detail: { currentElement, newElement } }); } @@ -4891,73 +5535,13 @@ Copyright © 2024 37signals LLC } } - function canRefreshFrame(frame) { - return frame instanceof FrameElement && - frame.src && - frame.refresh === "morph" && - !frame.closest("[data-turbo-permanent]") - } - - function refreshFrame(frame) { - frame.addEventListener("turbo:before-frame-render", ({ detail }) => { - detail.render = MorphingFrameRenderer.renderElement; - }, { once: true }); - - frame.reload(); - } - - class SnapshotCache { - keys = [] - snapshots = {} - + class SnapshotCache extends LRUCache { constructor(size) { - this.size = size; + super(size, toCacheKey); } - 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]; - } + get snapshots() { + return this.entries } } @@ -4971,10 +5555,10 @@ Copyright © 2024 37signals LLC } renderPage(snapshot, isPreview = false, willRender = true, visit) { - const shouldMorphPage = this.isPageRefresh(visit) && this.snapshot.shouldMorphPage; + const shouldMorphPage = this.isPageRefresh(visit) && (visit?.refresh?.method || this.snapshot.refreshMethod) === "morph"; const rendererClass = shouldMorphPage ? MorphingPageRenderer : PageRenderer; - const renderer = new rendererClass(this.snapshot, snapshot, rendererClass.renderElement, isPreview, willRender); + const renderer = new rendererClass(this.snapshot, snapshot, isPreview, willRender); if (!renderer.shouldRender) { this.forceReloaded = true; @@ -4987,7 +5571,7 @@ Copyright © 2024 37signals LLC renderError(snapshot, visit) { visit?.changeHistory(); - const renderer = new ErrorRenderer(this.snapshot, snapshot, ErrorRenderer.renderElement, false); + const renderer = new ErrorRenderer(this.snapshot, snapshot, false); return this.render(renderer) } @@ -5015,7 +5599,7 @@ Copyright © 2024 37signals LLC } shouldPreserveScrollPosition(visit) { - return this.isPageRefresh(visit) && this.snapshot.shouldPreserveScrollPosition + return this.isPageRefresh(visit) && (visit?.refresh?.scroll || this.snapshot.refreshScroll) === "preserve" } get snapshot() { @@ -5138,11 +5722,8 @@ Copyright © 2024 37signals LLC streamMessageRenderer = new StreamMessageRenderer() cache = new Cache(this) - drive = true enabled = true - progressBarDelay = 500 started = false - formMode = "on" #pageRefreshDebouncePeriod = 150 constructor(recentRequests) { @@ -5208,10 +5789,14 @@ Copyright © 2024 37signals LLC } } - refresh(url, requestId) { + refresh(url, options = {}) { + options = typeof options === "string" ? { requestId: options } : options; + + const { method, requestId, scroll } = options; const isRecentRequest = requestId && this.recentRequests.has(requestId); - if (!isRecentRequest && !this.navigator.currentVisit) { - this.visit(url, { action: "replace", shouldCacheSnapshot: false }); + const isCurrentUrl = url === document.baseURI; + if (!isRecentRequest && !this.navigator.currentVisit && isCurrentUrl) { + this.visit(url, { action: "replace", shouldCacheSnapshot: false, refresh: { method, scroll } }); } } @@ -5232,11 +5817,35 @@ Copyright © 2024 37signals LLC } 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; } - setFormMode(mode) { - this.formMode = mode; + 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() { @@ -5291,6 +5900,12 @@ Copyright © 2024 37signals LLC } } + historyPoppedWithEmptyState(location) { + this.history.replace(location); + this.view.lastRenderedLocation = location; + this.view.cacheSnapshot(); + } + // Scroll observer delegate scrollPositionChanged(position) { @@ -5310,7 +5925,8 @@ Copyright © 2024 37signals LLC canPrefetchRequestToLocation(link, location) { return ( this.elementIsNavigatable(link) && - locationIsVisitable(location, this.snapshot.rootLocation) + locationIsVisitable(location, this.snapshot.rootLocation) && + this.navigator.linkPrefetchingIsEnabledForLocation(location) ) } @@ -5334,7 +5950,7 @@ Copyright © 2024 37signals LLC // Navigator delegate allowsVisitingLocationWithAction(location, action) { - return this.locationWithActionIsSamePage(location, action) || this.applicationAllowsVisitingLocation(location) + return this.applicationAllowsVisitingLocation(location) } visitProposedToLocation(location, options) { @@ -5350,9 +5966,7 @@ Copyright © 2024 37signals LLC this.view.markVisitDirection(visit.direction); } extendURLWithDeprecatedProperties(visit.location); - if (!visit.silent) { - this.notifyApplicationAfterVisitingLocation(visit.location, visit.action); - } + this.notifyApplicationAfterVisitingLocation(visit.location, visit.action); } visitCompleted(visit) { @@ -5361,14 +5975,6 @@ Copyright © 2024 37signals LLC 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) { @@ -5408,9 +6014,7 @@ Copyright © 2024 37signals LLC // Page view delegate viewWillCacheSnapshot() { - if (!this.navigator.currentVisit?.silent) { - this.notifyApplicationBeforeCachingSnapshot(); - } + this.notifyApplicationBeforeCachingSnapshot(); } allowsImmediateRender({ element }, options) { @@ -5502,15 +6106,6 @@ Copyright © 2024 37signals LLC }) } - notifyApplicationAfterVisitingSamePageLocation(oldURL, newURL) { - dispatchEvent( - new HashChangeEvent("hashchange", { - oldURL: oldURL.toString(), - newURL: newURL.toString() - }) - ); - } - notifyApplicationAfterFrameLoad(frame) { return dispatch("turbo:frame-load", { target: frame }) } @@ -5526,12 +6121,12 @@ Copyright © 2024 37signals LLC // Helpers submissionIsNavigatable(form, submitter) { - if (this.formMode == "off") { + if (config.forms.mode == "off") { return false } else { const submitterIsNavigatable = submitter ? this.elementIsNavigatable(submitter) : true; - if (this.formMode == "optin") { + if (config.forms.mode == "optin") { return submitterIsNavigatable && form.closest('[data-turbo="true"]') != null } else { return submitterIsNavigatable && this.elementIsNavigatable(form) @@ -5544,7 +6139,7 @@ Copyright © 2024 37signals LLC const withinFrame = findClosestRecursively(element, "turbo-frame"); // Check if Drive is enabled on the session or we're within a Frame. - if (this.drive || withinFrame) { + if (config.drive.enabled || withinFrame) { // Element is navigatable by default, unless `data-turbo="false"`. if (container) { return container.getAttribute("data-turbo") != "false" @@ -5596,7 +6191,9 @@ Copyright © 2024 37signals LLC }; const session = new Session(recentRequests); - const { cache, navigator: navigator$1 } = session; + + // Rename `navigator` to avoid shadowing `window.navigator` + const { cache, navigator: sessionNavigator } = session; /** * Starts the main session. @@ -5662,19 +6259,6 @@ Copyright © 2024 37signals LLC 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. * @@ -5686,36 +6270,75 @@ Copyright © 2024 37signals LLC * @param delay Time to delay in milliseconds */ function setProgressBarDelay(delay) { - session.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) { - FormSubmission.confirmMethod = 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) { - session.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; + } + + /** + * Morph the state of the currentBody based on the attributes and contents of + * the newBody. Morphing body elements may dispatch turbo:morph, + * turbo:before-morph-element, turbo:before-morph-attribute, and + * turbo:morph-element events. + * + * @param currentBody HTMLBodyElement destination of morphing changes + * @param newBody HTMLBodyElement source of morphing changes + */ + function morphBodyElements(currentBody, newBody) { + MorphingPageRenderer.renderElement(currentBody, newBody); + } + + /** + * Morph the child elements of the currentFrame based on the child elements of + * the newFrame. Morphing turbo-frame elements may dispatch turbo:before-frame-morph, + * turbo:before-morph-element, turbo:before-morph-attribute, and + * turbo:morph-element events. + * + * @param currentFrame FrameElement destination of morphing children changes + * @param newFrame FrameElement source of morphing children changes + */ + function morphTurboFrameElements(currentFrame, newFrame) { + MorphingFrameRenderer.renderElement(currentFrame, newFrame); } var Turbo = /*#__PURE__*/Object.freeze({ __proto__: null, - navigator: navigator$1, - session: session, - cache: cache, PageRenderer: PageRenderer, PageSnapshot: PageSnapshot, FrameRenderer: FrameRenderer, fetch: fetchWithTurboHeaders, + config: config, + session: session, + cache: cache, + navigator: sessionNavigator, start: start, registerAdapter: registerAdapter, visit: visit, connectStreamSource: connectStreamSource, disconnectStreamSource: disconnectStreamSource, renderStreamMessage: renderStreamMessage, - clearCache: clearCache, setProgressBarDelay: setProgressBarDelay, setConfirmMethod: setConfirmMethod, - setFormMode: setFormMode + setFormMode: setFormMode, + morphBodyElements: morphBodyElements, + morphTurboFrameElements: morphTurboFrameElements, + morphChildren: morphChildren, + morphElements: morphElements }); class TurboFrameMissingError extends Error {} @@ -5727,6 +6350,7 @@ Copyright © 2024 37signals LLC #connected = false #hasBeenLoaded = false #ignoredAttributes = new Set() + #shouldMorphFrame = false action = null constructor(element) { @@ -5762,11 +6386,17 @@ Copyright © 2024 37signals LLC this.formLinkClickObserver.stop(); this.linkInterceptor.stop(); this.formSubmitObserver.stop(); + + if (!this.element.hasAttribute("recurse")) { + this.#currentFetchRequest?.cancel(); + } } } disabledChanged() { - if (this.loadingStyle == FrameLoadingStyle.eager) { + if (this.disabled) { + this.#currentFetchRequest?.cancel(); + } else if (this.loadingStyle == FrameLoadingStyle.eager) { this.#loadSourceURL(); } } @@ -5774,6 +6404,10 @@ Copyright © 2024 37signals LLC sourceURLChanged() { if (this.#isIgnoringChangesTo("src")) return + if (!this.sourceURL) { + this.#currentFetchRequest?.cancel(); + } + if (this.element.isConnected) { this.complete = false; } @@ -5784,7 +6418,10 @@ Copyright © 2024 37signals LLC } sourceURLReloaded() { - const { src } = this.element; + const { refresh, src } = this.element; + + this.#shouldMorphFrame = src && refresh === "morph"; + this.element.removeAttribute("complete"); this.element.src = null; this.element.src = src; @@ -5827,6 +6464,7 @@ Copyright © 2024 37signals LLC } } } finally { + this.#shouldMorphFrame = false; this.fetchResponseLoaded = () => Promise.resolve(); } } @@ -5871,15 +6509,18 @@ Copyright © 2024 37signals LLC } this.formSubmission = new FormSubmission(this, element, submitter); + const { fetchRequest } = this.formSubmission; - this.prepareRequest(fetchRequest); + const frame = this.#findFrameElement(element, submitter); + + this.prepareRequest(fetchRequest, frame); this.formSubmission.start(); } // Fetch request delegate - prepareRequest(request) { - request.headers["Turbo-Frame"] = this.id; + prepareRequest(request, frame = this) { + request.headers["Turbo-Frame"] = frame.id; if (this.currentNavigationElement?.hasAttribute("data-turbo-stream")) { request.acceptResponseType(StreamMessage.contentType); @@ -5951,6 +6592,7 @@ Copyright © 2024 37signals LLC detail: { newFrame, ...options }, cancelable: true }); + const { defaultPrevented, detail: { render } @@ -5991,10 +6633,11 @@ Copyright © 2024 37signals LLC 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 FrameRenderer(this, this.view.snapshot, snapshot, FrameRenderer.renderElement, false, false); + const renderer = new rendererClass(this, this.view.snapshot, snapshot, false, false); if (this.view.renderPromise) await this.view.renderPromise; this.changeHistory(); @@ -6119,7 +6762,9 @@ Copyright © 2024 37signals LLC #findFrameElement(element, submitter) { const id = getAttribute("data-turbo-frame", submitter, element) || this.element.getAttribute("target"); - return getFrameElementById(id) ?? this.element + const target = this.#getFrameElementById(id); + + return target instanceof FrameElement ? target : this.element } async extractForeignFrameElement(container) { @@ -6163,9 +6808,11 @@ Copyright © 2024 37signals LLC } if (id) { - const frameElement = getFrameElementById(id); + const frameElement = this.#getFrameElementById(id); if (frameElement) { return !frameElement.disabled + } else if (id == "_parent") { + return false } } @@ -6186,8 +6833,12 @@ Copyright © 2024 37signals LLC return this.element.id } + get disabled() { + return this.element.disabled + } + get enabled() { - return !this.element.disabled + return !this.disabled } get sourceURL() { @@ -6247,13 +6898,15 @@ Copyright © 2024 37signals LLC callback(); delete this.currentNavigationElement; } - } - function getFrameElementById(id) { - if (id != null) { - const element = document.getElementById(id); - if (element instanceof FrameElement) { - return element + #getFrameElementById(id) { + if (id != null) { + const element = id === "_parent" ? + this.element.parentElement.closest("turbo-frame") : + document.getElementById(id); + if (element instanceof FrameElement) { + return element + } } } } @@ -6278,6 +6931,7 @@ Copyright © 2024 37signals LLC const StreamActions = { after() { + this.removeDuplicateTargetSiblings(); this.targetElements.forEach((e) => e.parentElement?.insertBefore(this.templateContent, e.nextSibling)); }, @@ -6287,6 +6941,7 @@ Copyright © 2024 37signals LLC }, before() { + this.removeDuplicateTargetSiblings(); this.targetElements.forEach((e) => e.parentElement?.insertBefore(this.templateContent, e)); }, @@ -6325,7 +6980,11 @@ Copyright © 2024 37signals LLC }, refresh() { - session.refresh(this.baseURI, this.requestId); + const method = this.getAttribute("method"); + const requestId = this.requestId; + const scroll = this.getAttribute("scroll"); + + session.refresh(this.baseURI, { method, requestId, scroll }); } }; @@ -6397,7 +7056,24 @@ Copyright © 2024 37signals LLC * Gets the list of duplicate children (i.e. those with the same ID) */ get duplicateChildren() { - const existingChildren = this.targetElements.flatMap((e) => [...e.children]).filter((c) => !!c.id); + const existingChildren = this.targetElements.flatMap((e) => [...e.children]).filter((c) => !!c.getAttribute("id")); + const newChildrenIds = [...(this.templateContent?.children || [])].filter((c) => !!c.getAttribute("id")).map((c) => c.getAttribute("id")); + + return existingChildren.filter((c) => newChildrenIds.includes(c.getAttribute("id"))) + } + + /** + * Removes duplicate siblings (by ID) + */ + removeDuplicateTargetSiblings() { + this.duplicateSiblings.forEach((c) => c.remove()); + } + + /** + * Gets the list of duplicate siblings (i.e. those with the same ID) + */ + get duplicateSiblings() { + const existingChildren = this.targetElements.flatMap((e) => [...e.parentElement.children]).filter((c) => !!c.id); const newChildrenIds = [...(this.templateContent?.children || [])].filter((c) => !!c.id).map((c) => c.id); return existingChildren.filter((c) => newChildrenIds.includes(c.id)) @@ -6554,11 +7230,11 @@ Copyright © 2024 37signals LLC } (() => { - let element = document.currentScript; - if (!element) return - if (element.hasAttribute("data-turbo-suppress-warning")) return + const scriptElement = document.currentScript; + if (!scriptElement) return + if (scriptElement.hasAttribute("data-turbo-suppress-warning")) return - element = element.parentElement; + let element = scriptElement.parentElement; while (element) { if (element == document.body) { return console.warn( @@ -6572,7 +7248,7 @@ Copyright © 2024 37signals LLC —— Suppress this warning by adding a "data-turbo-suppress-warning" attribute to: %s `, - element.outerHTML + scriptElement.outerHTML ) } @@ -6596,14 +7272,18 @@ Copyright © 2024 37signals LLC exports.StreamElement = StreamElement; exports.StreamSourceElement = StreamSourceElement; exports.cache = cache; - exports.clearCache = clearCache; + exports.config = config; exports.connectStreamSource = connectStreamSource; exports.disconnectStreamSource = disconnectStreamSource; exports.fetch = fetchWithTurboHeaders; exports.fetchEnctypeFromString = fetchEnctypeFromString; exports.fetchMethodFromString = fetchMethodFromString; exports.isSafe = isSafe; - exports.navigator = navigator$1; + exports.morphBodyElements = morphBodyElements; + exports.morphChildren = morphChildren; + exports.morphElements = morphElements; + exports.morphTurboFrameElements = morphTurboFrameElements; + exports.navigator = sessionNavigator; exports.registerAdapter = registerAdapter; exports.renderStreamMessage = renderStreamMessage; exports.session = session; diff --git a/node_modules/@hotwired/turbo/package.json b/node_modules/@hotwired/turbo/package.json index 28ef82bfff..f588760764 100644 --- a/node_modules/@hotwired/turbo/package.json +++ b/node_modules/@hotwired/turbo/package.json @@ -1,6 +1,6 @@ { "name": "@hotwired/turbo", - "version": "8.0.5", + "version": "8.0.23", "description": "The speed of a single-page web application without having to write any JavaScript", "module": "dist/turbo.es2017-esm.js", "main": "dist/turbo.es2017-umd.js", @@ -34,18 +34,17 @@ }, "devDependencies": { "@open-wc/testing": "^3.1.7", - "@playwright/test": "^1.28.0", + "@playwright/test": "~1.51.1", "@rollup/plugin-node-resolve": "13.1.3", "@web/dev-server-esbuild": "^0.3.3", "@web/test-runner": "^0.15.0", "@web/test-runner-playwright": "^0.9.0", "arg": "^5.0.1", "body-parser": "^1.20.1", - "chai": "~4.3.4", "eslint": "^8.13.0", "express": "^4.18.2", - "idiomorph": "https://github.com/bigskysoftware/idiomorph.git", - "multer": "^1.4.2", + "idiomorph": "~0.7.4", + "multer": "^2.0.2", "rollup": "^2.35.1" }, "scripts": { @@ -59,10 +58,10 @@ "test:browser": "playwright test", "test:unit": "NODE_OPTIONS=--inspect web-test-runner", "test:unit:win": "SET NODE_OPTIONS=--inspect & web-test-runner", - "release": "yarn build && npm publish", + "release": "yarn build && yarn publish", "lint": "eslint . --ext .js" }, "engines": { - "node": ">= 14" + "node": ">= 18" } } diff --git a/package-lock.json b/package-lock.json index 4831f6e001..9f75a49938 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "dependencies": { "@cityssm/fa5-power-transforms-css": "github:cityssm/fa5-power-transforms-css", "@fontsource/raleway": "^4.5.0", - "@hotwired/turbo": "^8.0.5", + "@hotwired/turbo": "^8.0.23", "@popperjs/core": "^2.11.8", "@ungap/custom-elements": "^1.3.0", "ace-builds": "^1.32.7", @@ -51,11 +51,12 @@ "integrity": "sha512-Rzj90wbZQnNzazqzoiu5HzMEMdqMJLUVFOo699sinTXrZRm1aB5iX2HTiK2VlPnH4M6u8yYnJ7CebOyamfWlqw==" }, "node_modules/@hotwired/turbo": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/@hotwired/turbo/-/turbo-8.0.5.tgz", - "integrity": "sha512-TdZDA7fxVQ2ZycygvpnzjGPmFq4sO/E2QVg+2em/sJ3YTSsIWVEis8HmWlumz+c9DjWcUkcCuB+muF08TInpAQ==", + "version": "8.0.23", + "resolved": "https://registry.npmjs.org/@hotwired/turbo/-/turbo-8.0.23.tgz", + "integrity": "sha512-GZ7cijxEZ6Ig71u7rD6LHaRv/wcE/hNsc+nEfiWOkLNqUgLOwo5MNGWOy5ZV9ZUDSiQx1no7YxjTNnT4O6//cQ==", + "license": "MIT", "engines": { - "node": ">= 14" + "node": ">= 18" } }, "node_modules/@orchidjs/sifter": { @@ -362,9 +363,9 @@ "integrity": "sha512-Rzj90wbZQnNzazqzoiu5HzMEMdqMJLUVFOo699sinTXrZRm1aB5iX2HTiK2VlPnH4M6u8yYnJ7CebOyamfWlqw==" }, "@hotwired/turbo": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/@hotwired/turbo/-/turbo-8.0.5.tgz", - "integrity": "sha512-TdZDA7fxVQ2ZycygvpnzjGPmFq4sO/E2QVg+2em/sJ3YTSsIWVEis8HmWlumz+c9DjWcUkcCuB+muF08TInpAQ==" + "version": "8.0.23", + "resolved": "https://registry.npmjs.org/@hotwired/turbo/-/turbo-8.0.23.tgz", + "integrity": "sha512-GZ7cijxEZ6Ig71u7rD6LHaRv/wcE/hNsc+nEfiWOkLNqUgLOwo5MNGWOy5ZV9ZUDSiQx1no7YxjTNnT4O6//cQ==" }, "@orchidjs/sifter": { "version": "1.1.0", diff --git a/package.json b/package.json index e596d79a67..93f48cafb4 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "dependencies": { "@cityssm/fa5-power-transforms-css": "github:cityssm/fa5-power-transforms-css", "@fontsource/raleway": "^4.5.0", - "@hotwired/turbo": "^8.0.5", + "@hotwired/turbo": "^8.0.23", "@popperjs/core": "^2.11.8", "@ungap/custom-elements": "^1.3.0", "ace-builds": "^1.32.7",