/*! Turbo 8.0.4 Copyright © 2024 37signals LLC */ /** * The MIT License (MIT) * * Copyright (c) 2019 Javan Makhmali * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ ;(function (prototype) { if (typeof prototype.requestSubmit == 'function') return prototype.requestSubmit = function (submitter) { if (submitter) { validateSubmitter(submitter, this) submitter.click() } else { submitter = document.createElement('input') submitter.type = 'submit' submitter.hidden = true this.appendChild(submitter) submitter.click() this.removeChild(submitter) } } function validateSubmitter(submitter, form) { submitter instanceof HTMLElement || raise(TypeError, "parameter 1 is not of type 'HTMLElement'") submitter.type == 'submit' || raise(TypeError, 'The specified element is not a submit button') submitter.form == form || raise(DOMException, 'The specified element is not owned by this form element', 'NotFoundError') } function raise(errorConstructor, message, name) { throw new errorConstructor("Failed to execute 'requestSubmit' on 'HTMLFormElement': " + message + '.', name) } })(HTMLFormElement.prototype) const submittersByForm = new WeakMap() function findSubmitterFromClickTarget(target) { const element = target instanceof Element ? target : target instanceof Node ? target.parentElement : null const candidate = element ? element.closest('input, button') : null return candidate?.type == 'submit' ? candidate : null } function clickCaptured(event) { const submitter = findSubmitterFromClickTarget(event.target) if (submitter && submitter.form) { submittersByForm.set(submitter.form, submitter) } } ;(function () { if ('submitter' in Event.prototype) return let prototype = window.Event.prototype // Certain versions of Safari 15 have a bug where they won't // populate the submitter. This hurts TurboDrive's enable/disable detection. // See https://bugs.webkit.org/show_bug.cgi?id=229660 if ('SubmitEvent' in window) { const prototypeOfSubmitEvent = window.SubmitEvent.prototype if (/Apple Computer/.test(navigator.vendor) && !('submitter' in prototypeOfSubmitEvent)) { prototype = prototypeOfSubmitEvent } else { return // polyfill not needed } } addEventListener('click', clickCaptured, true) Object.defineProperty(prototype, 'submitter', { get() { if (this.type == 'submit' && this.target instanceof HTMLFormElement) { return submittersByForm.get(this.target) } } }) })() const FrameLoadingStyle = { eager: 'eager', lazy: 'lazy' } /** * Contains a fragment of HTML which is updated based on navigation within * it (e.g. via links or form submissions). * * @customElement turbo-frame * @example * * * Show all expanded messages in this frame. * * *
* Show response from this form within this frame. *
*
*/ class FrameElement extends HTMLElement { static delegateConstructor = undefined loaded = Promise.resolve() static get observedAttributes() { return ['disabled', 'loading', 'src'] } constructor() { super() this.delegate = new FrameElement.delegateConstructor(this) } connectedCallback() { this.delegate.connect() } disconnectedCallback() { this.delegate.disconnect() } reload() { return this.delegate.sourceURLReloaded() } attributeChangedCallback(name) { if (name == 'loading') { this.delegate.loadingStyleChanged() } else if (name == 'src') { this.delegate.sourceURLChanged() } else if (name == 'disabled') { this.delegate.disabledChanged() } } /** * Gets the URL to lazily load source HTML from */ get src() { return this.getAttribute('src') } /** * Sets the URL to lazily load source HTML from */ set src(value) { if (value) { this.setAttribute('src', value) } else { this.removeAttribute('src') } } /** * Gets the refresh mode for the frame. */ get refresh() { return this.getAttribute('refresh') } /** * Sets the refresh mode for the frame. */ set refresh(value) { if (value) { this.setAttribute('refresh', value) } else { this.removeAttribute('refresh') } } /** * Determines if the element is loading */ get loading() { return frameLoadingStyleFromString(this.getAttribute('loading') || '') } /** * Sets the value of if the element is loading */ set loading(value) { if (value) { this.setAttribute('loading', value) } else { this.removeAttribute('loading') } } /** * Gets the disabled state of the frame. * * If disabled, no requests will be intercepted by the frame. */ get disabled() { return this.hasAttribute('disabled') } /** * Sets the disabled state of the frame. * * If disabled, no requests will be intercepted by the frame. */ set disabled(value) { if (value) { this.setAttribute('disabled', '') } else { this.removeAttribute('disabled') } } /** * Gets the autoscroll state of the frame. * * If true, the frame will be scrolled into view automatically on update. */ get autoscroll() { return this.hasAttribute('autoscroll') } /** * Sets the autoscroll state of the frame. * * If true, the frame will be scrolled into view automatically on update. */ set autoscroll(value) { if (value) { this.setAttribute('autoscroll', '') } else { this.removeAttribute('autoscroll') } } /** * Determines if the element has finished loading */ get complete() { return !this.delegate.isLoading } /** * Gets the active state of the frame. * * If inactive, source changes will not be observed. */ get isActive() { return this.ownerDocument === document && !this.isPreview } /** * Sets the active state of the frame. * * If inactive, source changes will not be observed. */ get isPreview() { return this.ownerDocument?.documentElement?.hasAttribute('data-turbo-preview') } } function frameLoadingStyleFromString(style) { switch (style.toLowerCase()) { case 'lazy': return FrameLoadingStyle.lazy default: return FrameLoadingStyle.eager } } function expandURL(locatable) { return new URL(locatable.toString(), document.baseURI) } function getAnchor(url) { let anchorMatch if (url.hash) { return url.hash.slice(1) // eslint-disable-next-line no-cond-assign } else if ((anchorMatch = url.href.match(/#(.*)$/))) { return anchorMatch[1] } } function getAction$1(form, submitter) { const action = submitter?.getAttribute('formaction') || form.getAttribute('action') || form.action return expandURL(action) } function getExtension(url) { return (getLastPathComponent(url).match(/\.[^.]*$/) || [])[0] || '' } function isHTML(url) { return !!getExtension(url).match(/^(?:|\.(?:htm|html|xhtml|php))$/) } function isPrefixedBy(baseURL, url) { const prefix = getPrefix(url) return baseURL.href === expandURL(prefix).href || baseURL.href.startsWith(prefix) } function locationIsVisitable(location, rootLocation) { return isPrefixedBy(location, rootLocation) && isHTML(location) } function getRequestURL(url) { const anchor = getAnchor(url) return anchor != null ? url.href.slice(0, -(anchor.length + 1)) : url.href } function toCacheKey(url) { return getRequestURL(url) } function urlsAreEqual(left, right) { return expandURL(left).href == expandURL(right).href } function getPathComponents(url) { return url.pathname.split('/').slice(1) } function getLastPathComponent(url) { return getPathComponents(url).slice(-1)[0] } function getPrefix(url) { return addTrailingSlash(url.origin + url.pathname) } function addTrailingSlash(value) { return value.endsWith('/') ? value : value + '/' } class FetchResponse { constructor(response) { this.response = response } get succeeded() { return this.response.ok } get failed() { return !this.succeeded } get clientError() { return this.statusCode >= 400 && this.statusCode <= 499 } get serverError() { return this.statusCode >= 500 && this.statusCode <= 599 } get redirected() { return this.response.redirected } get location() { return expandURL(this.response.url) } get isHTML() { return this.contentType && this.contentType.match(/^(?:text\/([^\s;,]+\b)?html|application\/xhtml\+xml)\b/) } get statusCode() { return this.response.status } get contentType() { return this.header('Content-Type') } get responseText() { return this.response.clone().text() } get responseHTML() { if (this.isHTML) { return this.response.clone().text() } else { return Promise.resolve(undefined) } } header(name) { return this.response.headers.get(name) } } function activateScriptElement(element) { if (element.getAttribute('data-turbo-eval') == 'false') { return element } else { const createdScriptElement = document.createElement('script') const cspNonce = getMetaContent('csp-nonce') if (cspNonce) { createdScriptElement.nonce = cspNonce } createdScriptElement.textContent = element.textContent createdScriptElement.async = false copyElementAttributes(createdScriptElement, element) return createdScriptElement } } function copyElementAttributes(destinationElement, sourceElement) { for (const { name, value } of sourceElement.attributes) { destinationElement.setAttribute(name, value) } } function createDocumentFragment(html) { const template = document.createElement('template') template.innerHTML = html return template.content } function dispatch(eventName, { target, cancelable, detail } = {}) { const event = new CustomEvent(eventName, { cancelable, bubbles: true, composed: true, detail }) if (target && target.isConnected) { target.dispatchEvent(event) } else { document.documentElement.dispatchEvent(event) } return event } function nextRepaint() { if (document.visibilityState === 'hidden') { return nextEventLoopTick() } else { return nextAnimationFrame() } } function nextAnimationFrame() { return new Promise(resolve => requestAnimationFrame(() => resolve())) } function nextEventLoopTick() { return new Promise(resolve => setTimeout(() => resolve(), 0)) } function nextMicrotask() { return Promise.resolve() } function parseHTMLDocument(html = '') { return new DOMParser().parseFromString(html, 'text/html') } function unindent(strings, ...values) { const lines = interpolate(strings, values).replace(/^\n/, '').split('\n') const match = lines[0].match(/^\s+/) const indent = match ? match[0].length : 0 return lines.map(line => line.slice(indent)).join('\n') } function interpolate(strings, values) { return strings.reduce((result, string, i) => { const value = values[i] == undefined ? '' : values[i] return result + string + value }, '') } function uuid() { return Array.from({ length: 36 }) .map((_, i) => { if (i == 8 || i == 13 || i == 18 || i == 23) { return '-' } else if (i == 14) { return '4' } else if (i == 19) { return (Math.floor(Math.random() * 4) + 8).toString(16) } else { return Math.floor(Math.random() * 15).toString(16) } }) .join('') } function getAttribute(attributeName, ...elements) { for (const value of elements.map(element => element?.getAttribute(attributeName))) { if (typeof value == 'string') return value } return null } function hasAttribute(attributeName, ...elements) { return elements.some(element => element && element.hasAttribute(attributeName)) } function markAsBusy(...elements) { for (const element of elements) { if (element.localName == 'turbo-frame') { element.setAttribute('busy', '') } element.setAttribute('aria-busy', 'true') } } function clearBusyState(...elements) { for (const element of elements) { if (element.localName == 'turbo-frame') { element.removeAttribute('busy') } element.removeAttribute('aria-busy') } } function waitForLoad(element, timeoutInMilliseconds = 2000) { return new Promise(resolve => { const onComplete = () => { element.removeEventListener('error', onComplete) element.removeEventListener('load', onComplete) resolve() } element.addEventListener('load', onComplete, { once: true }) element.addEventListener('error', onComplete, { once: true }) setTimeout(resolve, timeoutInMilliseconds) }) } function getHistoryMethodForAction(action) { switch (action) { case 'replace': return history.replaceState case 'advance': case 'restore': return history.pushState } } function isAction(action) { return action == 'advance' || action == 'replace' || action == 'restore' } function getVisitAction(...elements) { const action = getAttribute('data-turbo-action', ...elements) return isAction(action) ? action : null } function getMetaElement(name) { return document.querySelector(`meta[name="${name}"]`) } function getMetaContent(name) { const element = getMetaElement(name) return element && element.content } function setMetaContent(name, content) { let element = getMetaElement(name) if (!element) { element = document.createElement('meta') element.setAttribute('name', name) document.head.appendChild(element) } element.setAttribute('content', content) return element } function findClosestRecursively(element, selector) { if (element instanceof Element) { return ( element.closest(selector) || findClosestRecursively(element.assignedSlot || element.getRootNode()?.host, selector) ) } } function elementIsFocusable(element) { const inertDisabledOrHidden = '[inert], :disabled, [hidden], details:not([open]), dialog:not([open])' return !!element && element.closest(inertDisabledOrHidden) == null && typeof element.focus == 'function' } function queryAutofocusableElement(elementOrDocumentFragment) { return Array.from(elementOrDocumentFragment.querySelectorAll('[autofocus]')).find(elementIsFocusable) } async function around(callback, reader) { const before = reader() callback() await nextAnimationFrame() const after = reader() return [before, after] } function doesNotTargetIFrame(anchor) { if (anchor.hasAttribute('target')) { for (const element of document.getElementsByName(anchor.target)) { if (element instanceof HTMLIFrameElement) return false } } return true } function findLinkFromClickTarget(target) { return findClosestRecursively(target, 'a[href]:not([target^=_]):not([download])') } function getLocationForLink(link) { return expandURL(link.getAttribute('href') || '') } function debounce(fn, delay) { let timeoutId = null return (...args) => { const callback = () => fn.apply(this, args) clearTimeout(timeoutId) timeoutId = setTimeout(callback, delay) } } class LimitedSet extends Set { constructor(maxSize) { super() this.maxSize = maxSize } add(value) { if (this.size >= this.maxSize) { const iterator = this.values() const oldestValue = iterator.next().value this.delete(oldestValue) } super.add(value) } } const recentRequests = new LimitedSet(20) const nativeFetch = window.fetch function fetchWithTurboHeaders(url, options = {}) { const modifiedHeaders = new Headers(options.headers || {}) const requestUID = uuid() recentRequests.add(requestUID) modifiedHeaders.append('X-Turbo-Request-Id', requestUID) return nativeFetch(url, { ...options, headers: modifiedHeaders }) } function fetchMethodFromString(method) { switch (method.toLowerCase()) { case 'get': return FetchMethod.get case 'post': return FetchMethod.post case 'put': return FetchMethod.put case 'patch': return FetchMethod.patch case 'delete': return FetchMethod.delete } } const FetchMethod = { get: 'get', post: 'post', put: 'put', patch: 'patch', delete: 'delete' } function fetchEnctypeFromString(encoding) { switch (encoding.toLowerCase()) { case FetchEnctype.multipart: return FetchEnctype.multipart case FetchEnctype.plain: return FetchEnctype.plain default: return FetchEnctype.urlEncoded } } const FetchEnctype = { urlEncoded: 'application/x-www-form-urlencoded', multipart: 'multipart/form-data', plain: 'text/plain' } class FetchRequest { abortController = new AbortController() #resolveRequestPromise = _value => {} constructor( delegate, method, location, requestBody = new URLSearchParams(), target = null, enctype = FetchEnctype.urlEncoded ) { const [url, body] = buildResourceAndBody(expandURL(location), method, requestBody, enctype) this.delegate = delegate this.url = url this.target = target this.fetchOptions = { credentials: 'same-origin', redirect: 'follow', method: method, headers: { ...this.defaultHeaders }, body: body, signal: this.abortSignal, referrer: this.delegate.referrer?.href } this.enctype = enctype } get method() { return this.fetchOptions.method } set method(value) { const fetchBody = this.isSafe ? this.url.searchParams : this.fetchOptions.body || new FormData() const fetchMethod = fetchMethodFromString(value) || FetchMethod.get this.url.search = '' const [url, body] = buildResourceAndBody(this.url, fetchMethod, fetchBody, this.enctype) this.url = url this.fetchOptions.body = body this.fetchOptions.method = fetchMethod } get headers() { return this.fetchOptions.headers } set headers(value) { this.fetchOptions.headers = value } get body() { if (this.isSafe) { return this.url.searchParams } else { return this.fetchOptions.body } } set body(value) { this.fetchOptions.body = value } get location() { return this.url } get params() { return this.url.searchParams } get entries() { return this.body ? Array.from(this.body.entries()) : [] } cancel() { this.abortController.abort() } async perform() { const { fetchOptions } = this this.delegate.prepareRequest(this) const event = await this.#allowRequestToBeIntercepted(fetchOptions) try { this.delegate.requestStarted(this) if (event.detail.fetchRequest) { this.response = event.detail.fetchRequest.response } else { this.response = fetchWithTurboHeaders(this.url.href, fetchOptions) } const response = await this.response return await this.receive(response) } catch (error) { if (error.name !== 'AbortError') { if (this.#willDelegateErrorHandling(error)) { this.delegate.requestErrored(this, error) } throw error } } finally { this.delegate.requestFinished(this) } } async receive(response) { const fetchResponse = new FetchResponse(response) const event = dispatch('turbo:before-fetch-response', { cancelable: true, detail: { fetchResponse }, target: this.target }) if (event.defaultPrevented) { this.delegate.requestPreventedHandlingResponse(this, fetchResponse) } else if (fetchResponse.succeeded) { this.delegate.requestSucceededWithResponse(this, fetchResponse) } else { this.delegate.requestFailedWithResponse(this, fetchResponse) } return fetchResponse } get defaultHeaders() { return { Accept: 'text/html, application/xhtml+xml' } } get isSafe() { return isSafe(this.method) } get abortSignal() { return this.abortController.signal } acceptResponseType(mimeType) { this.headers['Accept'] = [mimeType, this.headers['Accept']].join(', ') } async #allowRequestToBeIntercepted(fetchOptions) { const requestInterception = new Promise(resolve => (this.#resolveRequestPromise = resolve)) const event = dispatch('turbo:before-fetch-request', { cancelable: true, detail: { fetchOptions, url: this.url, resume: this.#resolveRequestPromise }, target: this.target }) this.url = event.detail.url if (event.defaultPrevented) await requestInterception return event } #willDelegateErrorHandling(error) { const event = dispatch('turbo:fetch-request-error', { target: this.target, cancelable: true, detail: { request: this, error: error } }) return !event.defaultPrevented } } function isSafe(fetchMethod) { return fetchMethodFromString(fetchMethod) == FetchMethod.get } function buildResourceAndBody(resource, method, requestBody, enctype) { const searchParams = Array.from(requestBody).length > 0 ? new URLSearchParams(entriesExcludingFiles(requestBody)) : resource.searchParams if (isSafe(method)) { return [mergeIntoURLSearchParams(resource, searchParams), null] } else if (enctype == FetchEnctype.urlEncoded) { return [resource, searchParams] } else { return [resource, requestBody] } } function entriesExcludingFiles(requestBody) { const entries = [] for (const [name, value] of requestBody) { if (value instanceof File) continue else entries.push([name, value]) } return entries } function mergeIntoURLSearchParams(url, requestBody) { const searchParams = new URLSearchParams(entriesExcludingFiles(requestBody)) url.search = searchParams.toString() return url } class AppearanceObserver { started = false constructor(delegate, element) { this.delegate = delegate this.element = element this.intersectionObserver = new IntersectionObserver(this.intersect) } start() { if (!this.started) { this.started = true this.intersectionObserver.observe(this.element) } } stop() { if (this.started) { this.started = false this.intersectionObserver.unobserve(this.element) } } intersect = entries => { const lastEntry = entries.slice(-1)[0] if (lastEntry?.isIntersecting) { this.delegate.elementAppearedInViewport(this.element) } } } class StreamMessage { static contentType = 'text/vnd.turbo-stream.html' static wrap(message) { if (typeof message == 'string') { return new this(createDocumentFragment(message)) } else { return message } } constructor(fragment) { this.fragment = importStreamElements(fragment) } } function importStreamElements(fragment) { for (const element of fragment.querySelectorAll('turbo-stream')) { const streamElement = document.importNode(element, true) for (const inertScriptElement of streamElement.templateElement.content.querySelectorAll('script')) { inertScriptElement.replaceWith(activateScriptElement(inertScriptElement)) } element.replaceWith(streamElement) } return fragment } const PREFETCH_DELAY = 100 class PrefetchCache { #prefetchTimeout = null #prefetched = null get(url) { if (this.#prefetched && this.#prefetched.url === url && this.#prefetched.expire > Date.now()) { return this.#prefetched.request } } setLater(url, request, ttl) { this.clear() this.#prefetchTimeout = setTimeout(() => { request.perform() this.set(url, request, ttl) this.#prefetchTimeout = null }, PREFETCH_DELAY) } set(url, request, ttl) { this.#prefetched = { url, request, expire: new Date(new Date().getTime() + ttl) } } clear() { if (this.#prefetchTimeout) clearTimeout(this.#prefetchTimeout) this.#prefetched = null } } const cacheTtl = 10 * 1000 const prefetchCache = new PrefetchCache() const FormSubmissionState = { initialized: 'initialized', requesting: 'requesting', waiting: 'waiting', receiving: 'receiving', stopping: 'stopping', stopped: 'stopped' } class FormSubmission { state = FormSubmissionState.initialized static confirmMethod(message, _element, _submitter) { return Promise.resolve(confirm(message)) } constructor(delegate, formElement, submitter, mustRedirect = false) { const method = getMethod(formElement, submitter) const action = getAction(getFormAction(formElement, submitter), method) const body = buildFormData(formElement, submitter) const enctype = getEnctype(formElement, submitter) this.delegate = delegate this.formElement = formElement this.submitter = submitter this.fetchRequest = new FetchRequest(this, method, action, body, formElement, enctype) this.mustRedirect = mustRedirect } get method() { return this.fetchRequest.method } set method(value) { this.fetchRequest.method = value } get action() { return this.fetchRequest.url.toString() } set action(value) { this.fetchRequest.url = expandURL(value) } get body() { return this.fetchRequest.body } get enctype() { return this.fetchRequest.enctype } get isSafe() { return this.fetchRequest.isSafe } get location() { return this.fetchRequest.url } // The submission process async start() { const { initialized, requesting } = FormSubmissionState const confirmationMessage = getAttribute('data-turbo-confirm', this.submitter, this.formElement) if (typeof confirmationMessage === 'string') { const answer = await FormSubmission.confirmMethod(confirmationMessage, this.formElement, this.submitter) if (!answer) { return } } if (this.state == initialized) { this.state = requesting return this.fetchRequest.perform() } } stop() { const { stopping, stopped } = FormSubmissionState if (this.state != stopping && this.state != stopped) { this.state = stopping this.fetchRequest.cancel() return true } } // Fetch request delegate prepareRequest(request) { if (!request.isSafe) { const token = getCookieValue(getMetaContent('csrf-param')) || getMetaContent('csrf-token') if (token) { request.headers['X-CSRF-Token'] = token } } if (this.requestAcceptsTurboStreamResponse(request)) { request.acceptResponseType(StreamMessage.contentType) } } requestStarted(_request) { this.state = FormSubmissionState.waiting this.submitter?.setAttribute('disabled', '') this.setSubmitsWith() markAsBusy(this.formElement) dispatch('turbo:submit-start', { target: this.formElement, detail: { formSubmission: this } }) this.delegate.formSubmissionStarted(this) } requestPreventedHandlingResponse(request, response) { prefetchCache.clear() this.result = { success: response.succeeded, fetchResponse: response } } requestSucceededWithResponse(request, response) { if (response.clientError || response.serverError) { this.delegate.formSubmissionFailedWithResponse(this, response) return } prefetchCache.clear() if (this.requestMustRedirect(request) && responseSucceededWithoutRedirect(response)) { const error = new Error('Form responses must redirect to another location') this.delegate.formSubmissionErrored(this, error) } else { this.state = FormSubmissionState.receiving this.result = { success: true, fetchResponse: response } this.delegate.formSubmissionSucceededWithResponse(this, response) } } requestFailedWithResponse(request, response) { this.result = { success: false, fetchResponse: response } this.delegate.formSubmissionFailedWithResponse(this, response) } requestErrored(request, error) { this.result = { success: false, error } this.delegate.formSubmissionErrored(this, error) } requestFinished(_request) { this.state = FormSubmissionState.stopped this.submitter?.removeAttribute('disabled') this.resetSubmitterText() clearBusyState(this.formElement) dispatch('turbo:submit-end', { target: this.formElement, detail: { formSubmission: this, ...this.result } }) this.delegate.formSubmissionFinished(this) } // Private setSubmitsWith() { if (!this.submitter || !this.submitsWith) return if (this.submitter.matches('button')) { this.originalSubmitText = this.submitter.innerHTML this.submitter.innerHTML = this.submitsWith } else if (this.submitter.matches('input')) { const input = this.submitter this.originalSubmitText = input.value input.value = this.submitsWith } } resetSubmitterText() { if (!this.submitter || !this.originalSubmitText) return if (this.submitter.matches('button')) { this.submitter.innerHTML = this.originalSubmitText } else if (this.submitter.matches('input')) { const input = this.submitter input.value = this.originalSubmitText } } requestMustRedirect(request) { return !request.isSafe && this.mustRedirect } requestAcceptsTurboStreamResponse(request) { return !request.isSafe || hasAttribute('data-turbo-stream', this.submitter, this.formElement) } get submitsWith() { return this.submitter?.getAttribute('data-turbo-submits-with') } } function buildFormData(formElement, submitter) { const formData = new FormData(formElement) const name = submitter?.getAttribute('name') const value = submitter?.getAttribute('value') if (name) { formData.append(name, value || '') } return formData } function getCookieValue(cookieName) { if (cookieName != null) { const cookies = document.cookie ? document.cookie.split('; ') : [] const cookie = cookies.find(cookie => cookie.startsWith(cookieName)) if (cookie) { const value = cookie.split('=').slice(1).join('=') return value ? decodeURIComponent(value) : undefined } } } function responseSucceededWithoutRedirect(response) { return response.statusCode == 200 && !response.redirected } function getFormAction(formElement, submitter) { const formElementAction = typeof formElement.action === 'string' ? formElement.action : null if (submitter?.hasAttribute('formaction')) { return submitter.getAttribute('formaction') || '' } else { return formElement.getAttribute('action') || formElementAction || '' } } function getAction(formAction, fetchMethod) { const action = expandURL(formAction) if (isSafe(fetchMethod)) { action.search = '' } return action } function getMethod(formElement, submitter) { const method = submitter?.getAttribute('formmethod') || formElement.getAttribute('method') || '' return fetchMethodFromString(method.toLowerCase()) || FetchMethod.get } function getEnctype(formElement, submitter) { return fetchEnctypeFromString(submitter?.getAttribute('formenctype') || formElement.enctype) } class Snapshot { constructor(element) { this.element = element } get activeElement() { return this.element.ownerDocument.activeElement } get children() { return [...this.element.children] } hasAnchor(anchor) { return this.getElementForAnchor(anchor) != null } getElementForAnchor(anchor) { return anchor ? this.element.querySelector(`[id='${anchor}'], a[name='${anchor}']`) : null } get isConnected() { return this.element.isConnected } get firstAutofocusableElement() { return queryAutofocusableElement(this.element) } get permanentElements() { return queryPermanentElementsAll(this.element) } getPermanentElementById(id) { return getPermanentElementById(this.element, id) } getPermanentElementMapForSnapshot(snapshot) { const permanentElementMap = {} for (const currentPermanentElement of this.permanentElements) { const { id } = currentPermanentElement const newPermanentElement = snapshot.getPermanentElementById(id) if (newPermanentElement) { permanentElementMap[id] = [currentPermanentElement, newPermanentElement] } } return permanentElementMap } } function getPermanentElementById(node, id) { return node.querySelector(`#${id}[data-turbo-permanent]`) } function queryPermanentElementsAll(node) { return node.querySelectorAll('[id][data-turbo-permanent]') } class FormSubmitObserver { started = false constructor(delegate, eventTarget) { this.delegate = delegate this.eventTarget = eventTarget } start() { if (!this.started) { this.eventTarget.addEventListener('submit', this.submitCaptured, true) this.started = true } } stop() { if (this.started) { this.eventTarget.removeEventListener('submit', this.submitCaptured, true) this.started = false } } submitCaptured = () => { this.eventTarget.removeEventListener('submit', this.submitBubbled, false) this.eventTarget.addEventListener('submit', this.submitBubbled, false) } submitBubbled = event => { if (!event.defaultPrevented) { const form = event.target instanceof HTMLFormElement ? event.target : undefined const submitter = event.submitter || undefined if ( form && submissionDoesNotDismissDialog(form, submitter) && submissionDoesNotTargetIFrame(form, submitter) && this.delegate.willSubmitForm(form, submitter) ) { event.preventDefault() event.stopImmediatePropagation() this.delegate.formSubmitted(form, submitter) } } } } function submissionDoesNotDismissDialog(form, submitter) { const method = submitter?.getAttribute('formmethod') || form.getAttribute('method') return method != 'dialog' } function submissionDoesNotTargetIFrame(form, submitter) { if (submitter?.hasAttribute('formtarget') || form.hasAttribute('target')) { const target = submitter?.getAttribute('formtarget') || form.target for (const element of document.getElementsByName(target)) { if (element instanceof HTMLIFrameElement) return false } return true } else { return true } } class View { #resolveRenderPromise = _value => {} #resolveInterceptionPromise = _value => {} constructor(delegate, element) { this.delegate = delegate this.element = element } // Scrolling scrollToAnchor(anchor) { const element = this.snapshot.getElementForAnchor(anchor) if (element) { this.scrollToElement(element) this.focusElement(element) } else { this.scrollToPosition({ x: 0, y: 0 }) } } scrollToAnchorFromLocation(location) { this.scrollToAnchor(getAnchor(location)) } scrollToElement(element) { element.scrollIntoView() } focusElement(element) { if (element instanceof HTMLElement) { if (element.hasAttribute('tabindex')) { element.focus() } else { element.setAttribute('tabindex', '-1') element.focus() element.removeAttribute('tabindex') } } } scrollToPosition({ x, y }) { this.scrollRoot.scrollTo(x, y) } scrollToTop() { this.scrollToPosition({ x: 0, y: 0 }) } get scrollRoot() { return window } // Rendering async render(renderer) { const { isPreview, shouldRender, willRender, newSnapshot: snapshot } = renderer // A workaround to ignore tracked element mismatch reloads when performing // a promoted Visit from a frame navigation const shouldInvalidate = willRender if (shouldRender) { try { this.renderPromise = new Promise(resolve => (this.#resolveRenderPromise = resolve)) this.renderer = renderer await this.prepareToRenderSnapshot(renderer) const renderInterception = new Promise(resolve => (this.#resolveInterceptionPromise = resolve)) const options = { resume: this.#resolveInterceptionPromise, render: this.renderer.renderElement, renderMethod: this.renderer.renderMethod } const immediateRender = this.delegate.allowsImmediateRender(snapshot, options) if (!immediateRender) await renderInterception await this.renderSnapshot(renderer) this.delegate.viewRenderedSnapshot(snapshot, isPreview, this.renderer.renderMethod) this.delegate.preloadOnLoadLinksForView(this.element) this.finishRenderingSnapshot(renderer) } finally { delete this.renderer this.#resolveRenderPromise(undefined) delete this.renderPromise } } else if (shouldInvalidate) { this.invalidate(renderer.reloadReason) } } invalidate(reason) { this.delegate.viewInvalidated(reason) } async prepareToRenderSnapshot(renderer) { this.markAsPreview(renderer.isPreview) await renderer.prepareToRender() } markAsPreview(isPreview) { if (isPreview) { this.element.setAttribute('data-turbo-preview', '') } else { this.element.removeAttribute('data-turbo-preview') } } markVisitDirection(direction) { this.element.setAttribute('data-turbo-visit-direction', direction) } unmarkVisitDirection() { this.element.removeAttribute('data-turbo-visit-direction') } async renderSnapshot(renderer) { await renderer.render() } finishRenderingSnapshot(renderer) { renderer.finishRendering() } } class FrameView extends View { missing() { this.element.innerHTML = `Content missing` } get snapshot() { return new Snapshot(this.element) } } class LinkInterceptor { constructor(delegate, element) { this.delegate = delegate this.element = element } start() { this.element.addEventListener('click', this.clickBubbled) document.addEventListener('turbo:click', this.linkClicked) document.addEventListener('turbo:before-visit', this.willVisit) } stop() { this.element.removeEventListener('click', this.clickBubbled) document.removeEventListener('turbo:click', this.linkClicked) document.removeEventListener('turbo:before-visit', this.willVisit) } clickBubbled = event => { if (this.respondsToEventTarget(event.target)) { this.clickEvent = event } else { delete this.clickEvent } } linkClicked = event => { if (this.clickEvent && this.respondsToEventTarget(event.target) && event.target instanceof Element) { if (this.delegate.shouldInterceptLinkClick(event.target, event.detail.url, event.detail.originalEvent)) { this.clickEvent.preventDefault() event.preventDefault() this.delegate.linkClickIntercepted(event.target, event.detail.url, event.detail.originalEvent) } } delete this.clickEvent } willVisit = _event => { delete this.clickEvent } respondsToEventTarget(target) { const element = target instanceof Element ? target : target instanceof Node ? target.parentElement : null return element && element.closest('turbo-frame, html') == this.element } } class LinkClickObserver { started = false constructor(delegate, eventTarget) { this.delegate = delegate this.eventTarget = eventTarget } start() { if (!this.started) { this.eventTarget.addEventListener('click', this.clickCaptured, true) this.started = true } } stop() { if (this.started) { this.eventTarget.removeEventListener('click', this.clickCaptured, true) this.started = false } } clickCaptured = () => { this.eventTarget.removeEventListener('click', this.clickBubbled, false) this.eventTarget.addEventListener('click', this.clickBubbled, false) } clickBubbled = event => { if (event instanceof MouseEvent && this.clickEventIsSignificant(event)) { const target = (event.composedPath && event.composedPath()[0]) || event.target const link = findLinkFromClickTarget(target) if (link && doesNotTargetIFrame(link)) { const location = getLocationForLink(link) if (this.delegate.willFollowLinkToLocation(link, location, event)) { event.preventDefault() this.delegate.followedLinkToLocation(link, location) } } } } clickEventIsSignificant(event) { return !( (event.target && event.target.isContentEditable) || event.defaultPrevented || event.which > 1 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey ) } } class FormLinkClickObserver { constructor(delegate, element) { this.delegate = delegate this.linkInterceptor = new LinkClickObserver(this, element) } start() { this.linkInterceptor.start() } stop() { this.linkInterceptor.stop() } // Link hover observer delegate canPrefetchRequestToLocation(link, location) { return false } prefetchAndCacheRequestToLocation(link, location) { return } // Link click observer delegate willFollowLinkToLocation(link, location, originalEvent) { return ( this.delegate.willSubmitFormLinkToLocation(link, location, originalEvent) && (link.hasAttribute('data-turbo-method') || link.hasAttribute('data-turbo-stream')) ) } followedLinkToLocation(link, location) { const form = document.createElement('form') const type = 'hidden' for (const [name, value] of location.searchParams) { form.append(Object.assign(document.createElement('input'), { type, name, value })) } const action = Object.assign(location, { search: '' }) form.setAttribute('data-turbo', 'true') form.setAttribute('action', action.href) form.setAttribute('hidden', '') const method = link.getAttribute('data-turbo-method') if (method) form.setAttribute('method', method) const turboFrame = link.getAttribute('data-turbo-frame') if (turboFrame) form.setAttribute('data-turbo-frame', turboFrame) const turboAction = getVisitAction(link) if (turboAction) form.setAttribute('data-turbo-action', turboAction) const turboConfirm = link.getAttribute('data-turbo-confirm') if (turboConfirm) form.setAttribute('data-turbo-confirm', turboConfirm) const turboStream = link.hasAttribute('data-turbo-stream') if (turboStream) form.setAttribute('data-turbo-stream', '') this.delegate.submittedFormLinkToLocation(link, location, form) document.body.appendChild(form) form.addEventListener('turbo:submit-end', () => form.remove(), { once: true }) requestAnimationFrame(() => form.requestSubmit()) } } class Bardo { static async preservingPermanentElements(delegate, permanentElementMap, callback) { const bardo = new this(delegate, permanentElementMap) bardo.enter() await callback() bardo.leave() } constructor(delegate, permanentElementMap) { this.delegate = delegate this.permanentElementMap = permanentElementMap } enter() { for (const id in this.permanentElementMap) { const [currentPermanentElement, newPermanentElement] = this.permanentElementMap[id] this.delegate.enteringBardo(currentPermanentElement, newPermanentElement) this.replaceNewPermanentElementWithPlaceholder(newPermanentElement) } } leave() { for (const id in this.permanentElementMap) { const [currentPermanentElement] = this.permanentElementMap[id] this.replaceCurrentPermanentElementWithClone(currentPermanentElement) this.replacePlaceholderWithPermanentElement(currentPermanentElement) this.delegate.leavingBardo(currentPermanentElement) } } replaceNewPermanentElementWithPlaceholder(permanentElement) { const placeholder = createPlaceholderForPermanentElement(permanentElement) permanentElement.replaceWith(placeholder) } replaceCurrentPermanentElementWithClone(permanentElement) { const clone = permanentElement.cloneNode(true) permanentElement.replaceWith(clone) } replacePlaceholderWithPermanentElement(permanentElement) { const placeholder = this.getPlaceholderById(permanentElement.id) placeholder?.replaceWith(permanentElement) } getPlaceholderById(id) { return this.placeholders.find(element => element.content == id) } get placeholders() { return [...document.querySelectorAll('meta[name=turbo-permanent-placeholder][content]')] } } function createPlaceholderForPermanentElement(permanentElement) { const element = document.createElement('meta') element.setAttribute('name', 'turbo-permanent-placeholder') element.setAttribute('content', permanentElement.id) return element } class Renderer { #activeElement = null constructor(currentSnapshot, newSnapshot, renderElement, isPreview, willRender = true) { this.currentSnapshot = currentSnapshot this.newSnapshot = newSnapshot this.isPreview = isPreview this.willRender = willRender this.renderElement = renderElement this.promise = new Promise((resolve, reject) => (this.resolvingFunctions = { resolve, reject })) } get shouldRender() { return true } get reloadReason() { return } prepareToRender() { return } render() { // Abstract method } finishRendering() { if (this.resolvingFunctions) { this.resolvingFunctions.resolve() delete this.resolvingFunctions } } async preservingPermanentElements(callback) { await Bardo.preservingPermanentElements(this, this.permanentElementMap, callback) } focusFirstAutofocusableElement() { const element = this.connectedSnapshot.firstAutofocusableElement if (element) { element.focus() } } // Bardo delegate enteringBardo(currentPermanentElement) { if (this.#activeElement) return if (currentPermanentElement.contains(this.currentSnapshot.activeElement)) { this.#activeElement = this.currentSnapshot.activeElement } } leavingBardo(currentPermanentElement) { if (currentPermanentElement.contains(this.#activeElement) && this.#activeElement instanceof HTMLElement) { this.#activeElement.focus() this.#activeElement = null } } get connectedSnapshot() { return this.newSnapshot.isConnected ? this.newSnapshot : this.currentSnapshot } get currentElement() { return this.currentSnapshot.element } get newElement() { return this.newSnapshot.element } get permanentElementMap() { return this.currentSnapshot.getPermanentElementMapForSnapshot(this.newSnapshot) } get renderMethod() { return 'replace' } } class FrameRenderer extends Renderer { static renderElement(currentElement, newElement) { const destinationRange = document.createRange() destinationRange.selectNodeContents(currentElement) destinationRange.deleteContents() const frameElement = newElement const sourceRange = frameElement.ownerDocument?.createRange() if (sourceRange) { sourceRange.selectNodeContents(frameElement) currentElement.appendChild(sourceRange.extractContents()) } } constructor(delegate, currentSnapshot, newSnapshot, renderElement, isPreview, willRender = true) { super(currentSnapshot, newSnapshot, renderElement, isPreview, willRender) this.delegate = delegate } get shouldRender() { return true } async render() { await nextRepaint() this.preservingPermanentElements(() => { this.loadFrameElement() }) this.scrollFrameIntoView() await nextRepaint() this.focusFirstAutofocusableElement() await nextRepaint() this.activateScriptElements() } loadFrameElement() { this.delegate.willRenderFrame(this.currentElement, this.newElement) this.renderElement(this.currentElement, this.newElement) } scrollFrameIntoView() { if (this.currentElement.autoscroll || this.newElement.autoscroll) { const element = this.currentElement.firstElementChild const block = readScrollLogicalPosition(this.currentElement.getAttribute('data-autoscroll-block'), 'end') const behavior = readScrollBehavior(this.currentElement.getAttribute('data-autoscroll-behavior'), 'auto') if (element) { element.scrollIntoView({ block, behavior }) return true } } return false } activateScriptElements() { for (const inertScriptElement of this.newScriptElements) { const activatedScriptElement = activateScriptElement(inertScriptElement) inertScriptElement.replaceWith(activatedScriptElement) } } get newScriptElements() { return this.currentElement.querySelectorAll('script') } } function readScrollLogicalPosition(value, defaultValue) { if (value == 'end' || value == 'start' || value == 'center' || value == 'nearest') { return value } else { return defaultValue } } function readScrollBehavior(value, defaultValue) { if (value == 'auto' || value == 'smooth') { return value } else { return defaultValue } } class ProgressBar { static animationDuration = 300 /*ms*/ static get defaultCSS() { return unindent` .turbo-progress-bar { position: fixed; display: block; top: 0; left: 0; height: 3px; background: #0076ff; z-index: 2147483647; transition: width ${ProgressBar.animationDuration}ms ease-out, opacity ${ProgressBar.animationDuration / 2}ms ${ProgressBar.animationDuration / 2}ms ease-in; transform: translate3d(0, 0, 0); } ` } hiding = false value = 0 visible = false constructor() { this.stylesheetElement = this.createStylesheetElement() this.progressElement = this.createProgressElement() this.installStylesheetElement() this.setValue(0) } show() { if (!this.visible) { this.visible = true this.installProgressElement() this.startTrickling() } } hide() { if (this.visible && !this.hiding) { this.hiding = true this.fadeProgressElement(() => { this.uninstallProgressElement() this.stopTrickling() this.visible = false this.hiding = false }) } } setValue(value) { this.value = value this.refresh() } // Private installStylesheetElement() { document.head.insertBefore(this.stylesheetElement, document.head.firstChild) } installProgressElement() { this.progressElement.style.width = '0' this.progressElement.style.opacity = '1' document.documentElement.insertBefore(this.progressElement, document.body) this.refresh() } fadeProgressElement(callback) { this.progressElement.style.opacity = '0' setTimeout(callback, ProgressBar.animationDuration * 1.5) } uninstallProgressElement() { if (this.progressElement.parentNode) { document.documentElement.removeChild(this.progressElement) } } startTrickling() { if (!this.trickleInterval) { this.trickleInterval = window.setInterval(this.trickle, ProgressBar.animationDuration) } } stopTrickling() { window.clearInterval(this.trickleInterval) delete this.trickleInterval } trickle = () => { this.setValue(this.value + Math.random() / 100) } refresh() { requestAnimationFrame(() => { this.progressElement.style.width = `${10 + this.value * 90}%` }) } createStylesheetElement() { const element = document.createElement('style') element.type = 'text/css' element.textContent = ProgressBar.defaultCSS if (this.cspNonce) { element.nonce = this.cspNonce } return element } createProgressElement() { const element = document.createElement('div') element.className = 'turbo-progress-bar' return element } get cspNonce() { return getMetaContent('csp-nonce') } } class HeadSnapshot extends Snapshot { detailsByOuterHTML = this.children .filter(element => !elementIsNoscript(element)) .map(element => elementWithoutNonce(element)) .reduce((result, element) => { const { outerHTML } = element const details = outerHTML in result ? result[outerHTML] : { type: elementType(element), tracked: elementIsTracked(element), elements: [] } return { ...result, [outerHTML]: { ...details, elements: [...details.elements, element] } } }, {}) get trackedElementSignature() { return Object.keys(this.detailsByOuterHTML) .filter(outerHTML => this.detailsByOuterHTML[outerHTML].tracked) .join('') } getScriptElementsNotInSnapshot(snapshot) { return this.getElementsMatchingTypeNotInSnapshot('script', snapshot) } getStylesheetElementsNotInSnapshot(snapshot) { return this.getElementsMatchingTypeNotInSnapshot('stylesheet', snapshot) } getElementsMatchingTypeNotInSnapshot(matchedType, snapshot) { return Object.keys(this.detailsByOuterHTML) .filter(outerHTML => !(outerHTML in snapshot.detailsByOuterHTML)) .map(outerHTML => this.detailsByOuterHTML[outerHTML]) .filter(({ type }) => type == matchedType) .map(({ elements: [element] }) => element) } get provisionalElements() { return Object.keys(this.detailsByOuterHTML).reduce((result, outerHTML) => { const { type, tracked, elements } = this.detailsByOuterHTML[outerHTML] if (type == null && !tracked) { return [...result, ...elements] } else if (elements.length > 1) { return [...result, ...elements.slice(1)] } else { return result } }, []) } getMetaValue(name) { const element = this.findMetaElementByName(name) return element ? element.getAttribute('content') : null } findMetaElementByName(name) { return Object.keys(this.detailsByOuterHTML).reduce((result, outerHTML) => { const { elements: [element] } = this.detailsByOuterHTML[outerHTML] return elementIsMetaElementWithName(element, name) ? element : result }, undefined | undefined) } } function elementType(element) { if (elementIsScript(element)) { return 'script' } else if (elementIsStylesheet(element)) { return 'stylesheet' } } function elementIsTracked(element) { return element.getAttribute('data-turbo-track') == 'reload' } function elementIsScript(element) { const tagName = element.localName return tagName == 'script' } function elementIsNoscript(element) { const tagName = element.localName return tagName == 'noscript' } function elementIsStylesheet(element) { const tagName = element.localName return tagName == 'style' || (tagName == 'link' && element.getAttribute('rel') == 'stylesheet') } function elementIsMetaElementWithName(element, name) { const tagName = element.localName return tagName == 'meta' && element.getAttribute('name') == name } function elementWithoutNonce(element) { if (element.hasAttribute('nonce')) { element.setAttribute('nonce', '') } return element } class PageSnapshot extends Snapshot { static fromHTMLString(html = '') { return this.fromDocument(parseHTMLDocument(html)) } static fromElement(element) { return this.fromDocument(element.ownerDocument) } static fromDocument({ documentElement, body, head }) { return new this(documentElement, body, new HeadSnapshot(head)) } constructor(documentElement, body, headSnapshot) { super(body) this.documentElement = documentElement this.headSnapshot = headSnapshot } clone() { const clonedElement = this.element.cloneNode(true) const selectElements = this.element.querySelectorAll('select') const clonedSelectElements = clonedElement.querySelectorAll('select') for (const [index, source] of selectElements.entries()) { const clone = clonedSelectElements[index] for (const option of clone.selectedOptions) option.selected = false for (const option of source.selectedOptions) clone.options[option.index].selected = true } for (const clonedPasswordInput of clonedElement.querySelectorAll('input[type="password"]')) { clonedPasswordInput.value = '' } return new PageSnapshot(this.documentElement, clonedElement, this.headSnapshot) } get lang() { return this.documentElement.getAttribute('lang') } get headElement() { return this.headSnapshot.element } get rootLocation() { const root = this.getSetting('root') ?? '/' return expandURL(root) } get cacheControlValue() { return this.getSetting('cache-control') } get isPreviewable() { return this.cacheControlValue != 'no-preview' } get isCacheable() { return this.cacheControlValue != 'no-cache' } get isVisitable() { return this.getSetting('visit-control') != 'reload' } get prefersViewTransitions() { return this.headSnapshot.getMetaValue('view-transition') === 'same-origin' } get shouldMorphPage() { return this.getSetting('refresh-method') === 'morph' } get shouldPreserveScrollPosition() { return this.getSetting('refresh-scroll') === 'preserve' } // Private getSetting(name) { return this.headSnapshot.getMetaValue(`turbo-${name}`) } } class ViewTransitioner { #viewTransitionStarted = false #lastOperation = Promise.resolve() renderChange(useViewTransition, render) { if (useViewTransition && this.viewTransitionsAvailable && !this.#viewTransitionStarted) { this.#viewTransitionStarted = true this.#lastOperation = this.#lastOperation.then(async () => { await document.startViewTransition(render).finished }) } else { this.#lastOperation = this.#lastOperation.then(render) } return this.#lastOperation } get viewTransitionsAvailable() { return document.startViewTransition } } const defaultOptions = { action: 'advance', historyChanged: false, visitCachedSnapshot: () => {}, willRender: true, updateHistory: true, shouldCacheSnapshot: true, acceptsStreamResponse: false } const TimingMetric = { visitStart: 'visitStart', requestStart: 'requestStart', requestEnd: 'requestEnd', visitEnd: 'visitEnd' } const VisitState = { initialized: 'initialized', started: 'started', canceled: 'canceled', failed: 'failed', completed: 'completed' } const SystemStatusCode = { networkFailure: 0, timeoutFailure: -1, contentTypeMismatch: -2 } const Direction = { advance: 'forward', restore: 'back', replace: 'none' } class Visit { identifier = uuid() // Required by turbo-ios timingMetrics = {} followedRedirect = false historyChanged = false scrolled = false shouldCacheSnapshot = true acceptsStreamResponse = false snapshotCached = false state = VisitState.initialized viewTransitioner = new ViewTransitioner() constructor(delegate, location, restorationIdentifier, options = {}) { this.delegate = delegate this.location = location this.restorationIdentifier = restorationIdentifier || uuid() const { action, historyChanged, referrer, snapshot, snapshotHTML, response, visitCachedSnapshot, willRender, updateHistory, shouldCacheSnapshot, acceptsStreamResponse, direction } = { ...defaultOptions, ...options } this.action = action this.historyChanged = historyChanged this.referrer = referrer this.snapshot = snapshot this.snapshotHTML = snapshotHTML this.response = response this.isSamePage = this.delegate.locationWithActionIsSamePage(this.location, this.action) this.isPageRefresh = this.view.isPageRefresh(this) this.visitCachedSnapshot = visitCachedSnapshot this.willRender = willRender this.updateHistory = updateHistory this.scrolled = !willRender this.shouldCacheSnapshot = shouldCacheSnapshot this.acceptsStreamResponse = acceptsStreamResponse this.direction = direction || Direction[action] } get adapter() { return this.delegate.adapter } get view() { return this.delegate.view } get history() { return this.delegate.history } get restorationData() { return this.history.getRestorationDataForIdentifier(this.restorationIdentifier) } get silent() { return this.isSamePage } start() { if (this.state == VisitState.initialized) { this.recordTimingMetric(TimingMetric.visitStart) this.state = VisitState.started this.adapter.visitStarted(this) this.delegate.visitStarted(this) } } cancel() { if (this.state == VisitState.started) { if (this.request) { this.request.cancel() } this.cancelRender() this.state = VisitState.canceled } } complete() { if (this.state == VisitState.started) { this.recordTimingMetric(TimingMetric.visitEnd) this.adapter.visitCompleted(this) this.state = VisitState.completed this.followRedirect() if (!this.followedRedirect) { this.delegate.visitCompleted(this) } } } fail() { if (this.state == VisitState.started) { this.state = VisitState.failed this.adapter.visitFailed(this) this.delegate.visitCompleted(this) } } changeHistory() { if (!this.historyChanged && this.updateHistory) { const actionForHistory = this.location.href === this.referrer?.href ? 'replace' : this.action const method = getHistoryMethodForAction(actionForHistory) this.history.update(method, this.location, this.restorationIdentifier) this.historyChanged = true } } issueRequest() { if (this.hasPreloadedResponse()) { this.simulateRequest() } else if (this.shouldIssueRequest() && !this.request) { this.request = new FetchRequest(this, FetchMethod.get, this.location) this.request.perform() } } simulateRequest() { if (this.response) { this.startRequest() this.recordResponse() this.finishRequest() } } startRequest() { this.recordTimingMetric(TimingMetric.requestStart) this.adapter.visitRequestStarted(this) } recordResponse(response = this.response) { this.response = response if (response) { const { statusCode } = response if (isSuccessful(statusCode)) { this.adapter.visitRequestCompleted(this) } else { this.adapter.visitRequestFailedWithStatusCode(this, statusCode) } } } finishRequest() { this.recordTimingMetric(TimingMetric.requestEnd) this.adapter.visitRequestFinished(this) } loadResponse() { if (this.response) { const { statusCode, responseHTML } = this.response this.render(async () => { if (this.shouldCacheSnapshot) this.cacheSnapshot() if (this.view.renderPromise) await this.view.renderPromise if (isSuccessful(statusCode) && responseHTML != null) { const snapshot = PageSnapshot.fromHTMLString(responseHTML) await this.renderPageSnapshot(snapshot, false) this.adapter.visitRendered(this) this.complete() } else { await this.view.renderError(PageSnapshot.fromHTMLString(responseHTML), this) this.adapter.visitRendered(this) this.fail() } }) } } getCachedSnapshot() { const snapshot = this.view.getCachedSnapshotForLocation(this.location) || this.getPreloadedSnapshot() if (snapshot && (!getAnchor(this.location) || snapshot.hasAnchor(getAnchor(this.location)))) { if (this.action == 'restore' || snapshot.isPreviewable) { return snapshot } } } getPreloadedSnapshot() { if (this.snapshotHTML) { return PageSnapshot.fromHTMLString(this.snapshotHTML) } } hasCachedSnapshot() { return this.getCachedSnapshot() != null } loadCachedSnapshot() { const snapshot = this.getCachedSnapshot() if (snapshot) { const isPreview = this.shouldIssueRequest() this.render(async () => { this.cacheSnapshot() if (this.isSamePage || this.isPageRefresh) { this.adapter.visitRendered(this) } else { if (this.view.renderPromise) await this.view.renderPromise await this.renderPageSnapshot(snapshot, isPreview) this.adapter.visitRendered(this) if (!isPreview) { this.complete() } } }) } } followRedirect() { if (this.redirectedToLocation && !this.followedRedirect && this.response?.redirected) { this.adapter.visitProposedToLocation(this.redirectedToLocation, { action: 'replace', response: this.response, shouldCacheSnapshot: false, willRender: false }) this.followedRedirect = true } } goToSamePageAnchor() { if (this.isSamePage) { this.render(async () => { this.cacheSnapshot() this.performScroll() this.changeHistory() this.adapter.visitRendered(this) }) } } // Fetch request delegate prepareRequest(request) { if (this.acceptsStreamResponse) { request.acceptResponseType(StreamMessage.contentType) } } requestStarted() { this.startRequest() } requestPreventedHandlingResponse(_request, _response) {} async requestSucceededWithResponse(request, response) { const responseHTML = await response.responseHTML const { redirected, statusCode } = response if (responseHTML == undefined) { this.recordResponse({ statusCode: SystemStatusCode.contentTypeMismatch, redirected }) } else { this.redirectedToLocation = response.redirected ? response.location : undefined this.recordResponse({ statusCode: statusCode, responseHTML, redirected }) } } async requestFailedWithResponse(request, response) { const responseHTML = await response.responseHTML const { redirected, statusCode } = response if (responseHTML == undefined) { this.recordResponse({ statusCode: SystemStatusCode.contentTypeMismatch, redirected }) } else { this.recordResponse({ statusCode: statusCode, responseHTML, redirected }) } } requestErrored(_request, _error) { this.recordResponse({ statusCode: SystemStatusCode.networkFailure, redirected: false }) } requestFinished() { this.finishRequest() } // Scrolling performScroll() { if (!this.scrolled && !this.view.forceReloaded && !this.view.shouldPreserveScrollPosition(this)) { if (this.action == 'restore') { this.scrollToRestoredPosition() || this.scrollToAnchor() || this.view.scrollToTop() } else { this.scrollToAnchor() || this.view.scrollToTop() } if (this.isSamePage) { this.delegate.visitScrolledToSamePageLocation(this.view.lastRenderedLocation, this.location) } this.scrolled = true } } scrollToRestoredPosition() { const { scrollPosition } = this.restorationData if (scrollPosition) { this.view.scrollToPosition(scrollPosition) return true } } scrollToAnchor() { const anchor = getAnchor(this.location) if (anchor != null) { this.view.scrollToAnchor(anchor) return true } } // Instrumentation recordTimingMetric(metric) { this.timingMetrics[metric] = new Date().getTime() } getTimingMetrics() { return { ...this.timingMetrics } } // Private getHistoryMethodForAction(action) { switch (action) { case 'replace': return history.replaceState case 'advance': case 'restore': return history.pushState } } hasPreloadedResponse() { return typeof this.response == 'object' } shouldIssueRequest() { if (this.isSamePage) { return false } else if (this.action == 'restore') { return !this.hasCachedSnapshot() } else { return this.willRender } } cacheSnapshot() { if (!this.snapshotCached) { this.view.cacheSnapshot(this.snapshot).then(snapshot => snapshot && this.visitCachedSnapshot(snapshot)) this.snapshotCached = true } } async render(callback) { this.cancelRender() this.frame = await nextRepaint() await callback() delete this.frame } async renderPageSnapshot(snapshot, isPreview) { await this.viewTransitioner.renderChange(this.view.shouldTransitionTo(snapshot), async () => { await this.view.renderPage(snapshot, isPreview, this.willRender, this) this.performScroll() }) } cancelRender() { if (this.frame) { cancelAnimationFrame(this.frame) delete this.frame } } } function isSuccessful(statusCode) { return statusCode >= 200 && statusCode < 300 } class BrowserAdapter { progressBar = new ProgressBar() constructor(session) { this.session = session } visitProposedToLocation(location, options) { if (locationIsVisitable(location, this.navigator.rootLocation)) { this.navigator.startVisit(location, options?.restorationIdentifier || uuid(), options) } else { window.location.href = location.toString() } } visitStarted(visit) { this.location = visit.location visit.loadCachedSnapshot() visit.issueRequest() visit.goToSamePageAnchor() } visitRequestStarted(visit) { this.progressBar.setValue(0) if (visit.hasCachedSnapshot() || visit.action != 'restore') { this.showVisitProgressBarAfterDelay() } else { this.showProgressBar() } } visitRequestCompleted(visit) { visit.loadResponse() } visitRequestFailedWithStatusCode(visit, statusCode) { switch (statusCode) { case SystemStatusCode.networkFailure: case SystemStatusCode.timeoutFailure: case SystemStatusCode.contentTypeMismatch: return this.reload({ reason: 'request_failed', context: { statusCode } }) default: return visit.loadResponse() } } visitRequestFinished(_visit) {} visitCompleted(_visit) { this.progressBar.setValue(1) this.hideVisitProgressBar() } pageInvalidated(reason) { this.reload(reason) } visitFailed(_visit) { this.progressBar.setValue(1) this.hideVisitProgressBar() } visitRendered(_visit) {} // Form Submission Delegate formSubmissionStarted(_formSubmission) { this.progressBar.setValue(0) this.showFormProgressBarAfterDelay() } formSubmissionFinished(_formSubmission) { this.progressBar.setValue(1) this.hideFormProgressBar() } // Private showVisitProgressBarAfterDelay() { this.visitProgressBarTimeout = window.setTimeout(this.showProgressBar, this.session.progressBarDelay) } hideVisitProgressBar() { this.progressBar.hide() if (this.visitProgressBarTimeout != null) { window.clearTimeout(this.visitProgressBarTimeout) delete this.visitProgressBarTimeout } } showFormProgressBarAfterDelay() { if (this.formProgressBarTimeout == null) { this.formProgressBarTimeout = window.setTimeout(this.showProgressBar, this.session.progressBarDelay) } } hideFormProgressBar() { this.progressBar.hide() if (this.formProgressBarTimeout != null) { window.clearTimeout(this.formProgressBarTimeout) delete this.formProgressBarTimeout } } showProgressBar = () => { this.progressBar.show() } reload(reason) { dispatch('turbo:reload', { detail: reason }) window.location.href = this.location?.toString() || window.location.href } get navigator() { return this.session.navigator } } class CacheObserver { selector = '[data-turbo-temporary]' deprecatedSelector = '[data-turbo-cache=false]' started = false start() { if (!this.started) { this.started = true addEventListener('turbo:before-cache', this.removeTemporaryElements, false) } } stop() { if (this.started) { this.started = false removeEventListener('turbo:before-cache', this.removeTemporaryElements, false) } } removeTemporaryElements = _event => { for (const element of this.temporaryElements) { element.remove() } } get temporaryElements() { return [...document.querySelectorAll(this.selector), ...this.temporaryElementsWithDeprecation] } get temporaryElementsWithDeprecation() { const elements = document.querySelectorAll(this.deprecatedSelector) if (elements.length) { console.warn( `The ${this.deprecatedSelector} selector is deprecated and will be removed in a future version. Use ${this.selector} instead.` ) } return [...elements] } } class FrameRedirector { constructor(session, element) { this.session = session this.element = element this.linkInterceptor = new LinkInterceptor(this, element) this.formSubmitObserver = new FormSubmitObserver(this, element) } start() { this.linkInterceptor.start() this.formSubmitObserver.start() } stop() { this.linkInterceptor.stop() this.formSubmitObserver.stop() } // Link interceptor delegate shouldInterceptLinkClick(element, _location, _event) { return this.#shouldRedirect(element) } linkClickIntercepted(element, url, event) { const frame = this.#findFrameElement(element) if (frame) { frame.delegate.linkClickIntercepted(element, url, event) } } // Form submit observer delegate willSubmitForm(element, submitter) { return ( element.closest('turbo-frame') == null && this.#shouldSubmit(element, submitter) && this.#shouldRedirect(element, submitter) ) } formSubmitted(element, submitter) { const frame = this.#findFrameElement(element, submitter) if (frame) { frame.delegate.formSubmitted(element, submitter) } } #shouldSubmit(form, submitter) { const action = getAction$1(form, submitter) const meta = this.element.ownerDocument.querySelector(`meta[name="turbo-root"]`) const rootLocation = expandURL(meta?.content ?? '/') return this.#shouldRedirect(form, submitter) && locationIsVisitable(action, rootLocation) } #shouldRedirect(element, submitter) { const isNavigatable = element instanceof HTMLFormElement ? this.session.submissionIsNavigatable(element, submitter) : this.session.elementIsNavigatable(element) if (isNavigatable) { const frame = this.#findFrameElement(element, submitter) return frame ? frame != element.closest('turbo-frame') : false } else { return false } } #findFrameElement(element, submitter) { const id = submitter?.getAttribute('data-turbo-frame') || element.getAttribute('data-turbo-frame') if (id && id != '_top') { const frame = this.element.querySelector(`#${id}:not([disabled])`) if (frame instanceof FrameElement) { return frame } } } } class History { location restorationIdentifier = uuid() restorationData = {} started = false pageLoaded = false currentIndex = 0 constructor(delegate) { this.delegate = delegate } start() { if (!this.started) { addEventListener('popstate', this.onPopState, false) addEventListener('load', this.onPageLoad, false) this.currentIndex = history.state?.turbo?.restorationIndex || 0 this.started = true this.replace(new URL(window.location.href)) } } stop() { if (this.started) { removeEventListener('popstate', this.onPopState, false) removeEventListener('load', this.onPageLoad, false) this.started = false } } push(location, restorationIdentifier) { this.update(history.pushState, location, restorationIdentifier) } replace(location, restorationIdentifier) { this.update(history.replaceState, location, restorationIdentifier) } update(method, location, restorationIdentifier = uuid()) { if (method === history.pushState) ++this.currentIndex const state = { turbo: { restorationIdentifier, restorationIndex: this.currentIndex } } method.call(history, state, '', location.href) this.location = location this.restorationIdentifier = restorationIdentifier } // Restoration data getRestorationDataForIdentifier(restorationIdentifier) { return this.restorationData[restorationIdentifier] || {} } updateRestorationData(additionalData) { const { restorationIdentifier } = this const restorationData = this.restorationData[restorationIdentifier] this.restorationData[restorationIdentifier] = { ...restorationData, ...additionalData } } // Scroll restoration assumeControlOfScrollRestoration() { if (!this.previousScrollRestoration) { this.previousScrollRestoration = history.scrollRestoration ?? 'auto' history.scrollRestoration = 'manual' } } relinquishControlOfScrollRestoration() { if (this.previousScrollRestoration) { history.scrollRestoration = this.previousScrollRestoration delete this.previousScrollRestoration } } // Event handlers onPopState = event => { if (this.shouldHandlePopState()) { const { turbo } = event.state || {} if (turbo) { this.location = new URL(window.location.href) const { restorationIdentifier, restorationIndex } = turbo this.restorationIdentifier = restorationIdentifier const direction = restorationIndex > this.currentIndex ? 'forward' : 'back' this.delegate.historyPoppedToLocationWithRestorationIdentifierAndDirection( this.location, restorationIdentifier, direction ) this.currentIndex = restorationIndex } } } onPageLoad = async _event => { await nextMicrotask() this.pageLoaded = true } // Private shouldHandlePopState() { // Safari dispatches a popstate event after window's load event, ignore it return this.pageIsLoaded() } pageIsLoaded() { return this.pageLoaded || document.readyState == 'complete' } } class LinkPrefetchObserver { started = false #prefetchedLink = null constructor(delegate, eventTarget) { this.delegate = delegate this.eventTarget = eventTarget } start() { if (this.started) return if (this.eventTarget.readyState === 'loading') { this.eventTarget.addEventListener('DOMContentLoaded', this.#enable, { once: true }) } else { this.#enable() } } stop() { if (!this.started) return this.eventTarget.removeEventListener('mouseenter', this.#tryToPrefetchRequest, { capture: true, passive: true }) this.eventTarget.removeEventListener('mouseleave', this.#cancelRequestIfObsolete, { capture: true, passive: true }) this.eventTarget.removeEventListener('turbo:before-fetch-request', this.#tryToUsePrefetchedRequest, true) this.started = false } #enable = () => { this.eventTarget.addEventListener('mouseenter', this.#tryToPrefetchRequest, { capture: true, passive: true }) this.eventTarget.addEventListener('mouseleave', this.#cancelRequestIfObsolete, { capture: true, passive: true }) this.eventTarget.addEventListener('turbo:before-fetch-request', this.#tryToUsePrefetchedRequest, true) this.started = true } #tryToPrefetchRequest = event => { if (getMetaContent('turbo-prefetch') === 'false') return const target = event.target const isLink = target.matches && target.matches('a[href]:not([target^=_]):not([download])') if (isLink && this.#isPrefetchable(target)) { const link = target const location = getLocationForLink(link) if (this.delegate.canPrefetchRequestToLocation(link, location)) { this.#prefetchedLink = link const fetchRequest = new FetchRequest(this, FetchMethod.get, location, new URLSearchParams(), target) prefetchCache.setLater(location.toString(), fetchRequest, this.#cacheTtl) } } } #cancelRequestIfObsolete = event => { if (event.target === this.#prefetchedLink) this.#cancelPrefetchRequest() } #cancelPrefetchRequest = () => { prefetchCache.clear() this.#prefetchedLink = null } #tryToUsePrefetchedRequest = event => { if (event.target.tagName !== 'FORM' && event.detail.fetchOptions.method === 'get') { const cached = prefetchCache.get(event.detail.url.toString()) if (cached) { // User clicked link, use cache response event.detail.fetchRequest = cached } prefetchCache.clear() } } prepareRequest(request) { const link = request.target request.headers['X-Sec-Purpose'] = 'prefetch' const turboFrame = link.closest('turbo-frame') const turboFrameTarget = link.getAttribute('data-turbo-frame') || turboFrame?.getAttribute('target') || turboFrame?.id if (turboFrameTarget && turboFrameTarget !== '_top') { request.headers['Turbo-Frame'] = turboFrameTarget } } // Fetch request interface requestSucceededWithResponse() {} requestStarted(fetchRequest) {} requestErrored(fetchRequest) {} requestFinished(fetchRequest) {} requestPreventedHandlingResponse(fetchRequest, fetchResponse) {} requestFailedWithResponse(fetchRequest, fetchResponse) {} get #cacheTtl() { return Number(getMetaContent('turbo-prefetch-cache-time')) || cacheTtl } #isPrefetchable(link) { const href = link.getAttribute('href') if (!href) return false if (unfetchableLink(link)) return false if (linkToTheSamePage(link)) return false if (linkOptsOut(link)) return false if (nonSafeLink(link)) return false if (eventPrevented(link)) return false return true } } const unfetchableLink = link => { return ( link.origin !== document.location.origin || !['http:', 'https:'].includes(link.protocol) || link.hasAttribute('target') ) } const linkToTheSamePage = link => { return ( link.pathname + link.search === document.location.pathname + document.location.search || link.href.startsWith('#') ) } const linkOptsOut = link => { if (link.getAttribute('data-turbo-prefetch') === 'false') return true if (link.getAttribute('data-turbo') === 'false') return true const turboPrefetchParent = findClosestRecursively(link, '[data-turbo-prefetch]') if (turboPrefetchParent && turboPrefetchParent.getAttribute('data-turbo-prefetch') === 'false') return true return false } const nonSafeLink = link => { const turboMethod = link.getAttribute('data-turbo-method') if (turboMethod && turboMethod.toLowerCase() !== 'get') return true if (isUJS(link)) return true if (link.hasAttribute('data-turbo-confirm')) return true if (link.hasAttribute('data-turbo-stream')) return true return false } const isUJS = link => { return ( link.hasAttribute('data-remote') || link.hasAttribute('data-behavior') || link.hasAttribute('data-confirm') || link.hasAttribute('data-method') ) } const eventPrevented = link => { const event = dispatch('turbo:before-prefetch', { target: link, cancelable: true }) return event.defaultPrevented } class Navigator { constructor(delegate) { this.delegate = delegate } proposeVisit(location, options = {}) { if (this.delegate.allowsVisitingLocationWithAction(location, options.action)) { this.delegate.visitProposedToLocation(location, options) } } startVisit(locatable, restorationIdentifier, options = {}) { this.stop() this.currentVisit = new Visit(this, expandURL(locatable), restorationIdentifier, { referrer: this.location, ...options }) this.currentVisit.start() } submitForm(form, submitter) { this.stop() this.formSubmission = new FormSubmission(this, form, submitter, true) this.formSubmission.start() } stop() { if (this.formSubmission) { this.formSubmission.stop() delete this.formSubmission } if (this.currentVisit) { this.currentVisit.cancel() delete this.currentVisit } } get adapter() { return this.delegate.adapter } get view() { return this.delegate.view } get rootLocation() { return this.view.snapshot.rootLocation } get history() { return this.delegate.history } // Form submission delegate formSubmissionStarted(formSubmission) { // Not all adapters implement formSubmissionStarted if (typeof this.adapter.formSubmissionStarted === 'function') { this.adapter.formSubmissionStarted(formSubmission) } } async formSubmissionSucceededWithResponse(formSubmission, fetchResponse) { if (formSubmission == this.formSubmission) { const responseHTML = await fetchResponse.responseHTML if (responseHTML) { const shouldCacheSnapshot = formSubmission.isSafe if (!shouldCacheSnapshot) { this.view.clearSnapshotCache() } const { statusCode, redirected } = fetchResponse const action = this.#getActionForFormSubmission(formSubmission, fetchResponse) const visitOptions = { action, shouldCacheSnapshot, response: { statusCode, responseHTML, redirected } } this.proposeVisit(fetchResponse.location, visitOptions) } } } async formSubmissionFailedWithResponse(formSubmission, fetchResponse) { const responseHTML = await fetchResponse.responseHTML if (responseHTML) { const snapshot = PageSnapshot.fromHTMLString(responseHTML) if (fetchResponse.serverError) { await this.view.renderError(snapshot, this.currentVisit) } else { await this.view.renderPage(snapshot, false, true, this.currentVisit) } if (!snapshot.shouldPreserveScrollPosition) { this.view.scrollToTop() } this.view.clearSnapshotCache() } } formSubmissionErrored(formSubmission, error) { console.error(error) } formSubmissionFinished(formSubmission) { // Not all adapters implement formSubmissionFinished if (typeof this.adapter.formSubmissionFinished === 'function') { this.adapter.formSubmissionFinished(formSubmission) } } // Visit delegate visitStarted(visit) { this.delegate.visitStarted(visit) } visitCompleted(visit) { this.delegate.visitCompleted(visit) } locationWithActionIsSamePage(location, action) { const anchor = getAnchor(location) const currentAnchor = getAnchor(this.view.lastRenderedLocation) const isRestorationToTop = action === 'restore' && typeof anchor === 'undefined' return ( action !== 'replace' && getRequestURL(location) === getRequestURL(this.view.lastRenderedLocation) && (isRestorationToTop || (anchor != null && anchor !== currentAnchor)) ) } visitScrolledToSamePageLocation(oldURL, newURL) { this.delegate.visitScrolledToSamePageLocation(oldURL, newURL) } // Visits get location() { return this.history.location } get restorationIdentifier() { return this.history.restorationIdentifier } #getActionForFormSubmission(formSubmission, fetchResponse) { const { submitter, formElement } = formSubmission return getVisitAction(submitter, formElement) || this.#getDefaultAction(fetchResponse) } #getDefaultAction(fetchResponse) { const sameLocationRedirect = fetchResponse.redirected && fetchResponse.location.href === this.location?.href return sameLocationRedirect ? 'replace' : 'advance' } } const PageStage = { initial: 0, loading: 1, interactive: 2, complete: 3 } class PageObserver { stage = PageStage.initial started = false constructor(delegate) { this.delegate = delegate } start() { if (!this.started) { if (this.stage == PageStage.initial) { this.stage = PageStage.loading } document.addEventListener('readystatechange', this.interpretReadyState, false) addEventListener('pagehide', this.pageWillUnload, false) this.started = true } } stop() { if (this.started) { document.removeEventListener('readystatechange', this.interpretReadyState, false) removeEventListener('pagehide', this.pageWillUnload, false) this.started = false } } interpretReadyState = () => { const { readyState } = this if (readyState == 'interactive') { this.pageIsInteractive() } else if (readyState == 'complete') { this.pageIsComplete() } } pageIsInteractive() { if (this.stage == PageStage.loading) { this.stage = PageStage.interactive this.delegate.pageBecameInteractive() } } pageIsComplete() { this.pageIsInteractive() if (this.stage == PageStage.interactive) { this.stage = PageStage.complete this.delegate.pageLoaded() } } pageWillUnload = () => { this.delegate.pageWillUnload() } get readyState() { return document.readyState } } class ScrollObserver { started = false constructor(delegate) { this.delegate = delegate } start() { if (!this.started) { addEventListener('scroll', this.onScroll, false) this.onScroll() this.started = true } } stop() { if (this.started) { removeEventListener('scroll', this.onScroll, false) this.started = false } } onScroll = () => { this.updatePosition({ x: window.pageXOffset, y: window.pageYOffset }) } // Private updatePosition(position) { this.delegate.scrollPositionChanged(position) } } class StreamMessageRenderer { render({ fragment }) { Bardo.preservingPermanentElements(this, getPermanentElementMapForFragment(fragment), () => { withAutofocusFromFragment(fragment, () => { withPreservedFocus(() => { document.documentElement.appendChild(fragment) }) }) }) } // Bardo delegate enteringBardo(currentPermanentElement, newPermanentElement) { newPermanentElement.replaceWith(currentPermanentElement.cloneNode(true)) } leavingBardo() {} } function getPermanentElementMapForFragment(fragment) { const permanentElementsInDocument = queryPermanentElementsAll(document.documentElement) const permanentElementMap = {} for (const permanentElementInDocument of permanentElementsInDocument) { const { id } = permanentElementInDocument for (const streamElement of fragment.querySelectorAll('turbo-stream')) { const elementInStream = getPermanentElementById(streamElement.templateElement.content, id) if (elementInStream) { permanentElementMap[id] = [permanentElementInDocument, elementInStream] } } } return permanentElementMap } async function withAutofocusFromFragment(fragment, callback) { const generatedID = `turbo-stream-autofocus-${uuid()}` const turboStreams = fragment.querySelectorAll('turbo-stream') const elementWithAutofocus = firstAutofocusableElementInStreams(turboStreams) let willAutofocusId = null if (elementWithAutofocus) { if (elementWithAutofocus.id) { willAutofocusId = elementWithAutofocus.id } else { willAutofocusId = generatedID } elementWithAutofocus.id = willAutofocusId } callback() await nextRepaint() const hasNoActiveElement = document.activeElement == null || document.activeElement == document.body if (hasNoActiveElement && willAutofocusId) { const elementToAutofocus = document.getElementById(willAutofocusId) if (elementIsFocusable(elementToAutofocus)) { elementToAutofocus.focus() } if (elementToAutofocus && elementToAutofocus.id == generatedID) { elementToAutofocus.removeAttribute('id') } } } async function withPreservedFocus(callback) { const [activeElementBeforeRender, activeElementAfterRender] = await around(callback, () => document.activeElement) const restoreFocusTo = activeElementBeforeRender && activeElementBeforeRender.id if (restoreFocusTo) { const elementToFocus = document.getElementById(restoreFocusTo) if (elementIsFocusable(elementToFocus) && elementToFocus != activeElementAfterRender) { elementToFocus.focus() } } } function firstAutofocusableElementInStreams(nodeListOfStreamElements) { for (const streamElement of nodeListOfStreamElements) { const elementWithAutofocus = queryAutofocusableElement(streamElement.templateElement.content) if (elementWithAutofocus) return elementWithAutofocus } return null } class StreamObserver { sources = new Set() #started = false constructor(delegate) { this.delegate = delegate } start() { if (!this.#started) { this.#started = true addEventListener('turbo:before-fetch-response', this.inspectFetchResponse, false) } } stop() { if (this.#started) { this.#started = false removeEventListener('turbo:before-fetch-response', this.inspectFetchResponse, false) } } connectStreamSource(source) { if (!this.streamSourceIsConnected(source)) { this.sources.add(source) source.addEventListener('message', this.receiveMessageEvent, false) } } disconnectStreamSource(source) { if (this.streamSourceIsConnected(source)) { this.sources.delete(source) source.removeEventListener('message', this.receiveMessageEvent, false) } } streamSourceIsConnected(source) { return this.sources.has(source) } inspectFetchResponse = event => { const response = fetchResponseFromEvent(event) if (response && fetchResponseIsStream(response)) { event.preventDefault() this.receiveMessageResponse(response) } } receiveMessageEvent = event => { if (this.#started && typeof event.data == 'string') { this.receiveMessageHTML(event.data) } } async receiveMessageResponse(response) { const html = await response.responseHTML if (html) { this.receiveMessageHTML(html) } } receiveMessageHTML(html) { this.delegate.receivedMessageFromStream(StreamMessage.wrap(html)) } } function fetchResponseFromEvent(event) { const fetchResponse = event.detail?.fetchResponse if (fetchResponse instanceof FetchResponse) { return fetchResponse } } function fetchResponseIsStream(response) { const contentType = response.contentType ?? '' return contentType.startsWith(StreamMessage.contentType) } class ErrorRenderer extends Renderer { static renderElement(currentElement, newElement) { const { documentElement, body } = document documentElement.replaceChild(newElement, body) } async render() { this.replaceHeadAndBody() this.activateScriptElements() } replaceHeadAndBody() { const { documentElement, head } = document documentElement.replaceChild(this.newHead, head) this.renderElement(this.currentElement, this.newElement) } activateScriptElements() { for (const replaceableElement of this.scriptElements) { const parentNode = replaceableElement.parentNode if (parentNode) { const element = activateScriptElement(replaceableElement) parentNode.replaceChild(element, replaceableElement) } } } get newHead() { return this.newSnapshot.headSnapshot.element } get scriptElements() { return document.documentElement.querySelectorAll('script') } } // base IIFE to define idiomorph var Idiomorph = (function () { //============================================================================= // AND NOW IT BEGINS... //============================================================================= let EMPTY_SET = new Set() // default configuration values, updatable by users now let defaults = { morphStyle: 'outerHTML', callbacks: { beforeNodeAdded: noOp, afterNodeAdded: noOp, beforeNodeMorphed: noOp, afterNodeMorphed: noOp, beforeNodeRemoved: noOp, afterNodeRemoved: noOp, beforeAttributeUpdated: noOp }, head: { style: 'merge', shouldPreserve: function (elt) { return elt.getAttribute('im-preserve') === 'true' }, shouldReAppend: function (elt) { return elt.getAttribute('im-re-append') === 'true' }, shouldRemove: noOp, afterHeadMorphed: noOp } } //============================================================================= // Core Morphing Algorithm - morph, morphNormalizedContent, morphOldNodeTo, morphChildren //============================================================================= function morph(oldNode, newContent, config = {}) { if (oldNode instanceof Document) { oldNode = oldNode.documentElement } if (typeof newContent === 'string') { newContent = parseContent(newContent) } let normalizedContent = normalizeContent(newContent) let ctx = createMorphContext(oldNode, normalizedContent, config) return morphNormalizedContent(oldNode, normalizedContent, ctx) } function morphNormalizedContent(oldNode, normalizedNewContent, ctx) { if (ctx.head.block) { let oldHead = oldNode.querySelector('head') let newHead = normalizedNewContent.querySelector('head') if (oldHead && newHead) { let promises = handleHeadElement(newHead, oldHead, ctx) // when head promises resolve, call morph again, ignoring the head tag Promise.all(promises).then(function () { morphNormalizedContent( oldNode, normalizedNewContent, Object.assign(ctx, { head: { block: false, ignore: true } }) ) }) return } } if (ctx.morphStyle === 'innerHTML') { // innerHTML, so we are only updating the children morphChildren(normalizedNewContent, oldNode, ctx) return oldNode.children } else if (ctx.morphStyle === 'outerHTML' || ctx.morphStyle == null) { // otherwise find the best element match in the new content, morph that, and merge its siblings // into either side of the best match let bestMatch = findBestNodeMatch(normalizedNewContent, oldNode, ctx) // stash the siblings that will need to be inserted on either side of the best match let previousSibling = bestMatch?.previousSibling let nextSibling = bestMatch?.nextSibling // morph it let morphedNode = morphOldNodeTo(oldNode, bestMatch, ctx) if (bestMatch) { // if there was a best match, merge the siblings in too and return the // whole bunch return insertSiblings(previousSibling, morphedNode, nextSibling) } else { // otherwise nothing was added to the DOM return [] } } else { throw 'Do not understand how to morph style ' + ctx.morphStyle } } /** * @param possibleActiveElement * @param ctx * @returns {boolean} */ function ignoreValueOfActiveElement(possibleActiveElement, ctx) { return ( ctx.ignoreActiveValue && possibleActiveElement === document.activeElement && possibleActiveElement !== document.body ) } /** * @param oldNode root node to merge content into * @param newContent new content to merge * @param ctx the merge context * @returns {Element} the element that ended up in the DOM */ function morphOldNodeTo(oldNode, newContent, ctx) { if (ctx.ignoreActive && oldNode === document.activeElement); else if (newContent == null) { if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode oldNode.remove() ctx.callbacks.afterNodeRemoved(oldNode) return null } else if (!isSoftMatch(oldNode, newContent)) { if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode if (ctx.callbacks.beforeNodeAdded(newContent) === false) return oldNode oldNode.parentElement.replaceChild(newContent, oldNode) ctx.callbacks.afterNodeAdded(newContent) ctx.callbacks.afterNodeRemoved(oldNode) return newContent } else { if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) return oldNode if (oldNode instanceof HTMLHeadElement && ctx.head.ignore); else if (oldNode instanceof HTMLHeadElement && ctx.head.style !== 'morph') { handleHeadElement(newContent, oldNode, ctx) } else { syncNodeFrom(newContent, oldNode, ctx) if (!ignoreValueOfActiveElement(oldNode, ctx)) { morphChildren(newContent, oldNode, ctx) } } ctx.callbacks.afterNodeMorphed(oldNode, newContent) return oldNode } } /** * This is the core algorithm for matching up children. The idea is to use id sets to try to match up * nodes as faithfully as possible. We greedily match, which allows us to keep the algorithm fast, but * by using id sets, we are able to better match up with content deeper in the DOM. * * Basic algorithm is, for each node in the new content: * * - if we have reached the end of the old parent, append the new content * - if the new content has an id set match with the current insertion point, morph * - search for an id set match * - if id set match found, morph * - otherwise search for a "soft" match * - if a soft match is found, morph * - otherwise, prepend the new node before the current insertion point * * The two search algorithms terminate if competing node matches appear to outweigh what can be achieved * with the current node. See findIdSetMatch() and findSoftMatch() for details. * * @param {Element} newParent the parent element of the new content * @param {Element } oldParent the old content that we are merging the new content into * @param ctx the merge context */ function morphChildren(newParent, oldParent, ctx) { let nextNewChild = newParent.firstChild let insertionPoint = oldParent.firstChild let newChild // run through all the new content while (nextNewChild) { newChild = nextNewChild nextNewChild = newChild.nextSibling // if we are at the end of the exiting parent's children, just append if (insertionPoint == null) { if (ctx.callbacks.beforeNodeAdded(newChild) === false) return oldParent.appendChild(newChild) ctx.callbacks.afterNodeAdded(newChild) removeIdsFromConsideration(ctx, newChild) continue } // if the current node has an id set match then morph if (isIdSetMatch(newChild, insertionPoint, ctx)) { morphOldNodeTo(insertionPoint, newChild, ctx) insertionPoint = insertionPoint.nextSibling removeIdsFromConsideration(ctx, newChild) continue } // otherwise search forward in the existing old children for an id set match let idSetMatch = findIdSetMatch(newParent, oldParent, newChild, insertionPoint, ctx) // if we found a potential match, remove the nodes until that point and morph if (idSetMatch) { insertionPoint = removeNodesBetween(insertionPoint, idSetMatch, ctx) morphOldNodeTo(idSetMatch, newChild, ctx) removeIdsFromConsideration(ctx, newChild) continue } // no id set match found, so scan forward for a soft match for the current node let softMatch = findSoftMatch(newParent, oldParent, newChild, insertionPoint, ctx) // if we found a soft match for the current node, morph if (softMatch) { insertionPoint = removeNodesBetween(insertionPoint, softMatch, ctx) morphOldNodeTo(softMatch, newChild, ctx) removeIdsFromConsideration(ctx, newChild) continue } // abandon all hope of morphing, just insert the new child before the insertion point // and move on if (ctx.callbacks.beforeNodeAdded(newChild) === false) return oldParent.insertBefore(newChild, insertionPoint) ctx.callbacks.afterNodeAdded(newChild) removeIdsFromConsideration(ctx, newChild) } // remove any remaining old nodes that didn't match up with new content while (insertionPoint !== null) { let tempNode = insertionPoint insertionPoint = insertionPoint.nextSibling removeNode(tempNode, ctx) } } //============================================================================= // Attribute Syncing Code //============================================================================= /** * @param attr {String} the attribute to be mutated * @param to {Element} the element that is going to be updated * @param updateType {("update"|"remove")} * @param ctx the merge context * @returns {boolean} true if the attribute should be ignored, false otherwise */ function ignoreAttribute(attr, to, updateType, ctx) { if (attr === 'value' && ctx.ignoreActiveValue && to === document.activeElement) { return true } return ctx.callbacks.beforeAttributeUpdated(attr, to, updateType) === false } /** * syncs a given node with another node, copying over all attributes and * inner element state from the 'from' node to the 'to' node * * @param {Element} from the element to copy attributes & state from * @param {Element} to the element to copy attributes & state to * @param ctx the merge context */ function syncNodeFrom(from, to, ctx) { let type = from.nodeType // if is an element type, sync the attributes from the // new node into the new node if (type === 1 /* element type */) { const fromAttributes = from.attributes const toAttributes = to.attributes for (const fromAttribute of fromAttributes) { if (ignoreAttribute(fromAttribute.name, to, 'update', ctx)) { continue } if (to.getAttribute(fromAttribute.name) !== fromAttribute.value) { to.setAttribute(fromAttribute.name, fromAttribute.value) } } // iterate backwards to avoid skipping over items when a delete occurs for (let i = toAttributes.length - 1; 0 <= i; i--) { const toAttribute = toAttributes[i] if (ignoreAttribute(toAttribute.name, to, 'remove', ctx)) { continue } if (!from.hasAttribute(toAttribute.name)) { to.removeAttribute(toAttribute.name) } } } // sync text nodes if (type === 8 /* comment */ || type === 3 /* text */) { if (to.nodeValue !== from.nodeValue) { to.nodeValue = from.nodeValue } } if (!ignoreValueOfActiveElement(to, ctx)) { // sync input values syncInputValue(from, to, ctx) } } /** * @param from {Element} element to sync the value from * @param to {Element} element to sync the value to * @param attributeName {String} the attribute name * @param ctx the merge context */ function syncBooleanAttribute(from, to, attributeName, ctx) { if (from[attributeName] !== to[attributeName]) { let ignoreUpdate = ignoreAttribute(attributeName, to, 'update', ctx) if (!ignoreUpdate) { to[attributeName] = from[attributeName] } if (from[attributeName]) { if (!ignoreUpdate) { to.setAttribute(attributeName, from[attributeName]) } } else { if (!ignoreAttribute(attributeName, to, 'remove', ctx)) { to.removeAttribute(attributeName) } } } } /** * NB: many bothans died to bring us information: * * https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js * https://github.com/choojs/nanomorph/blob/master/lib/morph.jsL113 * * @param from {Element} the element to sync the input value from * @param to {Element} the element to sync the input value to * @param ctx the merge context */ function syncInputValue(from, to, ctx) { if (from instanceof HTMLInputElement && to instanceof HTMLInputElement && from.type !== 'file') { let fromValue = from.value let toValue = to.value // sync boolean attributes syncBooleanAttribute(from, to, 'checked', ctx) syncBooleanAttribute(from, to, 'disabled', ctx) if (!from.hasAttribute('value')) { if (!ignoreAttribute('value', to, 'remove', ctx)) { to.value = '' to.removeAttribute('value') } } else if (fromValue !== toValue) { if (!ignoreAttribute('value', to, 'update', ctx)) { to.setAttribute('value', fromValue) to.value = fromValue } } } else if (from instanceof HTMLOptionElement) { syncBooleanAttribute(from, to, 'selected', ctx) } else if (from instanceof HTMLTextAreaElement && to instanceof HTMLTextAreaElement) { let fromValue = from.value let toValue = to.value if (ignoreAttribute('value', to, 'update', ctx)) { return } if (fromValue !== toValue) { to.value = fromValue } if (to.firstChild && to.firstChild.nodeValue !== fromValue) { to.firstChild.nodeValue = fromValue } } } //============================================================================= // the HEAD tag can be handled specially, either w/ a 'merge' or 'append' style //============================================================================= function handleHeadElement(newHeadTag, currentHead, ctx) { let added = [] let removed = [] let preserved = [] let nodesToAppend = [] let headMergeStyle = ctx.head.style // put all new head elements into a Map, by their outerHTML let srcToNewHeadNodes = new Map() for (const newHeadChild of newHeadTag.children) { srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild) } // for each elt in the current head for (const currentHeadElt of currentHead.children) { // If the current head element is in the map let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML) let isReAppended = ctx.head.shouldReAppend(currentHeadElt) let isPreserved = ctx.head.shouldPreserve(currentHeadElt) if (inNewContent || isPreserved) { if (isReAppended) { // remove the current version and let the new version replace it and re-execute removed.push(currentHeadElt) } else { // this element already exists and should not be re-appended, so remove it from // the new content map, preserving it in the DOM srcToNewHeadNodes.delete(currentHeadElt.outerHTML) preserved.push(currentHeadElt) } } else { if (headMergeStyle === 'append') { // we are appending and this existing element is not new content // so if and only if it is marked for re-append do we do anything if (isReAppended) { removed.push(currentHeadElt) nodesToAppend.push(currentHeadElt) } } else { // if this is a merge, we remove this content since it is not in the new head if (ctx.head.shouldRemove(currentHeadElt) !== false) { removed.push(currentHeadElt) } } } } // Push the remaining new head elements in the Map into the // nodes to append to the head tag nodesToAppend.push(...srcToNewHeadNodes.values()) let promises = [] for (const newNode of nodesToAppend) { let newElt = document.createRange().createContextualFragment(newNode.outerHTML).firstChild if (ctx.callbacks.beforeNodeAdded(newElt) !== false) { if (newElt.href || newElt.src) { let resolve = null let promise = new Promise(function (_resolve) { resolve = _resolve }) newElt.addEventListener('load', function () { resolve() }) promises.push(promise) } currentHead.appendChild(newElt) ctx.callbacks.afterNodeAdded(newElt) added.push(newElt) } } // remove all removed elements, after we have appended the new elements to avoid // additional network requests for things like style sheets for (const removedElement of removed) { if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) { currentHead.removeChild(removedElement) ctx.callbacks.afterNodeRemoved(removedElement) } } ctx.head.afterHeadMorphed(currentHead, { added: added, kept: preserved, removed: removed }) return promises } function noOp() {} /* Deep merges the config object and the Idiomoroph.defaults object to produce a final configuration object */ function mergeDefaults(config) { let finalConfig = {} // copy top level stuff into final config Object.assign(finalConfig, defaults) Object.assign(finalConfig, config) // copy callbacks into final config (do this to deep merge the callbacks) finalConfig.callbacks = {} Object.assign(finalConfig.callbacks, defaults.callbacks) Object.assign(finalConfig.callbacks, config.callbacks) // copy head config into final config (do this to deep merge the head) finalConfig.head = {} Object.assign(finalConfig.head, defaults.head) Object.assign(finalConfig.head, config.head) return finalConfig } function createMorphContext(oldNode, newContent, config) { config = mergeDefaults(config) return { target: oldNode, newContent: newContent, config: config, morphStyle: config.morphStyle, ignoreActive: config.ignoreActive, ignoreActiveValue: config.ignoreActiveValue, idMap: createIdMap(oldNode, newContent), deadIds: new Set(), callbacks: config.callbacks, head: config.head } } function isIdSetMatch(node1, node2, ctx) { if (node1 == null || node2 == null) { return false } if (node1.nodeType === node2.nodeType && node1.tagName === node2.tagName) { if (node1.id !== '' && node1.id === node2.id) { return true } else { return getIdIntersectionCount(ctx, node1, node2) > 0 } } return false } function isSoftMatch(node1, node2) { if (node1 == null || node2 == null) { return false } return node1.nodeType === node2.nodeType && node1.tagName === node2.tagName } function removeNodesBetween(startInclusive, endExclusive, ctx) { while (startInclusive !== endExclusive) { let tempNode = startInclusive startInclusive = startInclusive.nextSibling removeNode(tempNode, ctx) } removeIdsFromConsideration(ctx, endExclusive) return endExclusive.nextSibling } //============================================================================= // Scans forward from the insertionPoint in the old parent looking for a potential id match // for the newChild. We stop if we find a potential id match for the new child OR // if the number of potential id matches we are discarding is greater than the // potential id matches for the new child //============================================================================= function findIdSetMatch(newContent, oldParent, newChild, insertionPoint, ctx) { // max id matches we are willing to discard in our search let newChildPotentialIdCount = getIdIntersectionCount(ctx, newChild, oldParent) let potentialMatch = null // only search forward if there is a possibility of an id match if (newChildPotentialIdCount > 0) { let potentialMatch = insertionPoint // if there is a possibility of an id match, scan forward // keep track of the potential id match count we are discarding (the // newChildPotentialIdCount must be greater than this to make it likely // worth it) let otherMatchCount = 0 while (potentialMatch != null) { // If we have an id match, return the current potential match if (isIdSetMatch(newChild, potentialMatch, ctx)) { return potentialMatch } // computer the other potential matches of this new content otherMatchCount += getIdIntersectionCount(ctx, potentialMatch, newContent) if (otherMatchCount > newChildPotentialIdCount) { // if we have more potential id matches in _other_ content, we // do not have a good candidate for an id match, so return null return null } // advanced to the next old content child potentialMatch = potentialMatch.nextSibling } } return potentialMatch } //============================================================================= // Scans forward from the insertionPoint in the old parent looking for a potential soft match // for the newChild. We stop if we find a potential soft match for the new child OR // if we find a potential id match in the old parents children OR if we find two // potential soft matches for the next two pieces of new content //============================================================================= function findSoftMatch(newContent, oldParent, newChild, insertionPoint, ctx) { let potentialSoftMatch = insertionPoint let nextSibling = newChild.nextSibling let siblingSoftMatchCount = 0 while (potentialSoftMatch != null) { if (getIdIntersectionCount(ctx, potentialSoftMatch, newContent) > 0) { // the current potential soft match has a potential id set match with the remaining new // content so bail out of looking return null } // if we have a soft match with the current node, return it if (isSoftMatch(newChild, potentialSoftMatch)) { return potentialSoftMatch } if (isSoftMatch(nextSibling, potentialSoftMatch)) { // the next new node has a soft match with this node, so // increment the count of future soft matches siblingSoftMatchCount++ nextSibling = nextSibling.nextSibling // If there are two future soft matches, bail to allow the siblings to soft match // so that we don't consume future soft matches for the sake of the current node if (siblingSoftMatchCount >= 2) { return null } } // advanced to the next old content child potentialSoftMatch = potentialSoftMatch.nextSibling } return potentialSoftMatch } function parseContent(newContent) { let parser = new DOMParser() // remove svgs to avoid false-positive matches on head, etc. let contentWithSvgsRemoved = newContent.replace(/]*>|>)([\s\S]*?)<\/svg>/gim, '') // if the newContent contains a html, head or body tag, we can simply parse it w/o wrapping if ( contentWithSvgsRemoved.match(/<\/html>/) || contentWithSvgsRemoved.match(/<\/head>/) || contentWithSvgsRemoved.match(/<\/body>/) ) { let content = parser.parseFromString(newContent, 'text/html') // if it is a full HTML document, return the document itself as the parent container if (contentWithSvgsRemoved.match(/<\/html>/)) { content.generatedByIdiomorph = true return content } else { // otherwise return the html element as the parent container let htmlElement = content.firstChild if (htmlElement) { htmlElement.generatedByIdiomorph = true return htmlElement } else { return null } } } else { // if it is partial HTML, wrap it in a template tag to provide a parent element and also to help // deal with touchy tags like tr, tbody, etc. let responseDoc = parser.parseFromString( '', 'text/html' ) let content = responseDoc.body.querySelector('template').content content.generatedByIdiomorph = true return content } } function normalizeContent(newContent) { if (newContent == null) { // noinspection UnnecessaryLocalVariableJS const dummyParent = document.createElement('div') return dummyParent } else if (newContent.generatedByIdiomorph) { // the template tag created by idiomorph parsing can serve as a dummy parent return newContent } else if (newContent instanceof Node) { // a single node is added as a child to a dummy parent const dummyParent = document.createElement('div') dummyParent.append(newContent) return dummyParent } else { // all nodes in the array or HTMLElement collection are consolidated under // a single dummy parent element const dummyParent = document.createElement('div') for (const elt of [...newContent]) { dummyParent.append(elt) } return dummyParent } } function insertSiblings(previousSibling, morphedNode, nextSibling) { let stack = [] let added = [] while (previousSibling != null) { stack.push(previousSibling) previousSibling = previousSibling.previousSibling } while (stack.length > 0) { let node = stack.pop() added.push(node) // push added preceding siblings on in order and insert morphedNode.parentElement.insertBefore(node, morphedNode) } added.push(morphedNode) while (nextSibling != null) { stack.push(nextSibling) added.push(nextSibling) // here we are going in order, so push on as we scan, rather than add nextSibling = nextSibling.nextSibling } while (stack.length > 0) { morphedNode.parentElement.insertBefore(stack.pop(), morphedNode.nextSibling) } return added } function findBestNodeMatch(newContent, oldNode, ctx) { let currentElement currentElement = newContent.firstChild let bestElement = currentElement let score = 0 while (currentElement) { let newScore = scoreElement(currentElement, oldNode, ctx) if (newScore > score) { bestElement = currentElement score = newScore } currentElement = currentElement.nextSibling } return bestElement } function scoreElement(node1, node2, ctx) { if (isSoftMatch(node1, node2)) { return 0.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 } })() class PageRenderer extends Renderer { static renderElement(currentElement, newElement) { if (document.body && newElement instanceof HTMLBodyElement) { document.body.replaceWith(newElement) } else { document.documentElement.appendChild(newElement) } } get shouldRender() { return this.newSnapshot.isVisitable && this.trackedElementsAreIdentical } get reloadReason() { if (!this.newSnapshot.isVisitable) { return { reason: 'turbo_visit_control_is_reload' } } if (!this.trackedElementsAreIdentical) { return { reason: 'tracked_element_mismatch' } } } async prepareToRender() { this.#setLanguage() await this.mergeHead() } async render() { if (this.willRender) { await this.replaceBody() } } finishRendering() { super.finishRendering() if (!this.isPreview) { this.focusFirstAutofocusableElement() } } get currentHeadSnapshot() { return this.currentSnapshot.headSnapshot } get newHeadSnapshot() { return this.newSnapshot.headSnapshot } get newElement() { return this.newSnapshot.element } #setLanguage() { const { documentElement } = this.currentSnapshot const { lang } = this.newSnapshot if (lang) { documentElement.setAttribute('lang', lang) } else { documentElement.removeAttribute('lang') } } async mergeHead() { const mergedHeadElements = this.mergeProvisionalElements() const newStylesheetElements = this.copyNewHeadStylesheetElements() this.copyNewHeadScriptElements() await mergedHeadElements await newStylesheetElements if (this.willRender) { this.removeUnusedDynamicStylesheetElements() } } async replaceBody() { await this.preservingPermanentElements(async () => { this.activateNewBody() await this.assignNewBody() }) } get trackedElementsAreIdentical() { return this.currentHeadSnapshot.trackedElementSignature == this.newHeadSnapshot.trackedElementSignature } async copyNewHeadStylesheetElements() { const loadingElements = [] for (const element of this.newHeadStylesheetElements) { loadingElements.push(waitForLoad(element)) document.head.appendChild(element) } await Promise.all(loadingElements) } copyNewHeadScriptElements() { for (const element of this.newHeadScriptElements) { document.head.appendChild(activateScriptElement(element)) } } removeUnusedDynamicStylesheetElements() { for (const element of this.unusedDynamicStylesheetElements) { document.head.removeChild(element) } } async mergeProvisionalElements() { const newHeadElements = [...this.newHeadProvisionalElements] for (const element of this.currentHeadProvisionalElements) { if (!this.isCurrentElementInElementList(element, newHeadElements)) { document.head.removeChild(element) } } for (const element of newHeadElements) { document.head.appendChild(element) } } isCurrentElementInElementList(element, elementList) { for (const [index, newElement] of elementList.entries()) { // if title element... if (element.tagName == 'TITLE') { if (newElement.tagName != 'TITLE') { continue } if (element.innerHTML == newElement.innerHTML) { elementList.splice(index, 1) return true } } // if any other element... if (newElement.isEqualNode(element)) { elementList.splice(index, 1) return true } } return false } removeCurrentHeadProvisionalElements() { for (const element of this.currentHeadProvisionalElements) { document.head.removeChild(element) } } copyNewHeadProvisionalElements() { for (const element of this.newHeadProvisionalElements) { document.head.appendChild(element) } } activateNewBody() { document.adoptNode(this.newElement) this.activateNewBodyScriptElements() } activateNewBodyScriptElements() { for (const inertScriptElement of this.newBodyScriptElements) { const activatedScriptElement = activateScriptElement(inertScriptElement) inertScriptElement.replaceWith(activatedScriptElement) } } async assignNewBody() { await this.renderElement(this.currentElement, this.newElement) } get unusedDynamicStylesheetElements() { return this.oldHeadStylesheetElements.filter(element => { return element.getAttribute('data-turbo-track') === 'dynamic' }) } get oldHeadStylesheetElements() { return this.currentHeadSnapshot.getStylesheetElementsNotInSnapshot(this.newHeadSnapshot) } get newHeadStylesheetElements() { return this.newHeadSnapshot.getStylesheetElementsNotInSnapshot(this.currentHeadSnapshot) } get newHeadScriptElements() { return this.newHeadSnapshot.getScriptElementsNotInSnapshot(this.currentHeadSnapshot) } get currentHeadProvisionalElements() { return this.currentHeadSnapshot.provisionalElements } get newHeadProvisionalElements() { return this.newHeadSnapshot.provisionalElements } get newBodyScriptElements() { return this.newElement.querySelectorAll('script') } } class MorphRenderer extends PageRenderer { async render() { if (this.willRender) await this.#morphBody() } get renderMethod() { return 'morph' } // Private async #morphBody() { this.#morphElements(this.currentElement, this.newElement) this.#reloadRemoteFrames() dispatch('turbo:morph', { detail: { currentElement: this.currentElement, newElement: this.newElement } }) } #morphElements(currentElement, newElement, morphStyle = 'outerHTML') { this.isMorphingTurboFrame = this.#isFrameReloadedWithMorph(currentElement) Idiomorph.morph(currentElement, newElement, { morphStyle: morphStyle, callbacks: { beforeNodeAdded: this.#shouldAddElement, beforeNodeMorphed: this.#shouldMorphElement, beforeAttributeUpdated: this.#shouldUpdateAttribute, beforeNodeRemoved: this.#shouldRemoveElement, afterNodeMorphed: this.#didMorphElement } }) } #shouldAddElement = node => { return !(node.id && node.hasAttribute('data-turbo-permanent') && document.getElementById(node.id)) } #shouldMorphElement = (oldNode, newNode) => { if (oldNode instanceof HTMLElement) { if ( !oldNode.hasAttribute('data-turbo-permanent') && (this.isMorphingTurboFrame || !this.#isFrameReloadedWithMorph(oldNode)) ) { const event = dispatch('turbo:before-morph-element', { cancelable: true, target: oldNode, detail: { newElement: newNode } }) return !event.defaultPrevented } else { return false } } } #shouldUpdateAttribute = (attributeName, target, mutationType) => { const event = dispatch('turbo:before-morph-attribute', { cancelable: true, target, detail: { attributeName, mutationType } }) return !event.defaultPrevented } #didMorphElement = (oldNode, newNode) => { if (newNode instanceof HTMLElement) { dispatch('turbo:morph-element', { target: oldNode, detail: { newElement: newNode } }) } } #shouldRemoveElement = node => { return this.#shouldMorphElement(node) } #reloadRemoteFrames() { this.#remoteFrames().forEach(frame => { if (this.#isFrameReloadedWithMorph(frame)) { this.#renderFrameWithMorph(frame) frame.reload() } }) } #renderFrameWithMorph(frame) { frame.addEventListener( 'turbo:before-frame-render', event => { event.detail.render = this.#morphFrameUpdate }, { once: true } ) } #morphFrameUpdate = (currentElement, newElement) => { dispatch('turbo:before-frame-morph', { target: currentElement, detail: { currentElement, newElement } }) this.#morphElements(currentElement, newElement.children, 'innerHTML') } #isFrameReloadedWithMorph(element) { return element.src && element.refresh === 'morph' } #remoteFrames() { return Array.from(document.querySelectorAll('turbo-frame[src]')).filter(frame => { return !frame.closest('[data-turbo-permanent]') }) } } class SnapshotCache { keys = [] snapshots = {} constructor(size) { this.size = size } has(location) { return toCacheKey(location) in this.snapshots } get(location) { if (this.has(location)) { const snapshot = this.read(location) this.touch(location) return snapshot } } put(location, snapshot) { this.write(location, snapshot) this.touch(location) return snapshot } clear() { this.snapshots = {} } // Private read(location) { return this.snapshots[toCacheKey(location)] } write(location, snapshot) { this.snapshots[toCacheKey(location)] = snapshot } touch(location) { const key = toCacheKey(location) const index = this.keys.indexOf(key) if (index > -1) this.keys.splice(index, 1) this.keys.unshift(key) this.trim() } trim() { for (const key of this.keys.splice(this.size)) { delete this.snapshots[key] } } } class PageView extends View { snapshotCache = new SnapshotCache(10) lastRenderedLocation = new URL(location.href) forceReloaded = false shouldTransitionTo(newSnapshot) { return this.snapshot.prefersViewTransitions && newSnapshot.prefersViewTransitions } renderPage(snapshot, isPreview = false, willRender = true, visit) { const shouldMorphPage = this.isPageRefresh(visit) && this.snapshot.shouldMorphPage const rendererClass = shouldMorphPage ? MorphRenderer : PageRenderer const renderer = new rendererClass(this.snapshot, snapshot, PageRenderer.renderElement, isPreview, willRender) if (!renderer.shouldRender) { this.forceReloaded = true } else { visit?.changeHistory() } return this.render(renderer) } renderError(snapshot, visit) { visit?.changeHistory() const renderer = new ErrorRenderer(this.snapshot, snapshot, ErrorRenderer.renderElement, false) return this.render(renderer) } clearSnapshotCache() { this.snapshotCache.clear() } async cacheSnapshot(snapshot = this.snapshot) { if (snapshot.isCacheable) { this.delegate.viewWillCacheSnapshot() const { lastRenderedLocation: location } = this await nextEventLoopTick() const cachedSnapshot = snapshot.clone() this.snapshotCache.put(location, cachedSnapshot) return cachedSnapshot } } getCachedSnapshotForLocation(location) { return this.snapshotCache.get(location) } isPageRefresh(visit) { return !visit || (this.lastRenderedLocation.pathname === visit.location.pathname && visit.action === 'replace') } shouldPreserveScrollPosition(visit) { return this.isPageRefresh(visit) && this.snapshot.shouldPreserveScrollPosition } get snapshot() { return PageSnapshot.fromElement(this.element) } } class Preloader { selector = 'a[data-turbo-preload]' constructor(delegate, snapshotCache) { this.delegate = delegate this.snapshotCache = snapshotCache } start() { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', this.#preloadAll) } else { this.preloadOnLoadLinksForView(document.body) } } stop() { document.removeEventListener('DOMContentLoaded', this.#preloadAll) } preloadOnLoadLinksForView(element) { for (const link of element.querySelectorAll(this.selector)) { if (this.delegate.shouldPreloadLink(link)) { this.preloadURL(link) } } } async preloadURL(link) { const location = new URL(link.href) if (this.snapshotCache.has(location)) { return } const fetchRequest = new FetchRequest(this, FetchMethod.get, location, new URLSearchParams(), link) await fetchRequest.perform() } // Fetch request delegate prepareRequest(fetchRequest) { fetchRequest.headers['X-Sec-Purpose'] = 'prefetch' } async requestSucceededWithResponse(fetchRequest, fetchResponse) { try { const responseHTML = await fetchResponse.responseHTML const snapshot = PageSnapshot.fromHTMLString(responseHTML) this.snapshotCache.put(fetchRequest.url, snapshot) } catch (_) { // If we cannot preload that is ok! } } requestStarted(fetchRequest) {} requestErrored(fetchRequest) {} requestFinished(fetchRequest) {} requestPreventedHandlingResponse(fetchRequest, fetchResponse) {} requestFailedWithResponse(fetchRequest, fetchResponse) {} #preloadAll = () => { this.preloadOnLoadLinksForView(document.body) } } class Cache { constructor(session) { this.session = session } clear() { this.session.clearCache() } resetCacheControl() { this.#setCacheControl('') } exemptPageFromCache() { this.#setCacheControl('no-cache') } exemptPageFromPreview() { this.#setCacheControl('no-preview') } #setCacheControl(value) { setMetaContent('turbo-cache-control', value) } } class Session { navigator = new Navigator(this) history = new History(this) view = new PageView(this, document.documentElement) adapter = new BrowserAdapter(this) pageObserver = new PageObserver(this) cacheObserver = new CacheObserver() linkPrefetchObserver = new LinkPrefetchObserver(this, document) linkClickObserver = new LinkClickObserver(this, window) formSubmitObserver = new FormSubmitObserver(this, document) scrollObserver = new ScrollObserver(this) streamObserver = new StreamObserver(this) formLinkClickObserver = new FormLinkClickObserver(this, document.documentElement) frameRedirector = new FrameRedirector(this, document.documentElement) streamMessageRenderer = new StreamMessageRenderer() cache = new Cache(this) drive = true enabled = true progressBarDelay = 500 started = false formMode = 'on' #pageRefreshDebouncePeriod = 150 constructor(recentRequests) { this.recentRequests = recentRequests this.preloader = new Preloader(this, this.view.snapshotCache) this.debouncedRefresh = this.refresh this.pageRefreshDebouncePeriod = this.pageRefreshDebouncePeriod } start() { if (!this.started) { this.pageObserver.start() this.cacheObserver.start() this.linkPrefetchObserver.start() this.formLinkClickObserver.start() this.linkClickObserver.start() this.formSubmitObserver.start() this.scrollObserver.start() this.streamObserver.start() this.frameRedirector.start() this.history.start() this.preloader.start() this.started = true this.enabled = true } } disable() { this.enabled = false } stop() { if (this.started) { this.pageObserver.stop() this.cacheObserver.stop() this.linkPrefetchObserver.stop() this.formLinkClickObserver.stop() this.linkClickObserver.stop() this.formSubmitObserver.stop() this.scrollObserver.stop() this.streamObserver.stop() this.frameRedirector.stop() this.history.stop() this.preloader.stop() this.started = false } } registerAdapter(adapter) { this.adapter = adapter } visit(location, options = {}) { const frameElement = options.frame ? document.getElementById(options.frame) : null if (frameElement instanceof FrameElement) { const action = options.action || getVisitAction(frameElement) frameElement.delegate.proposeVisitIfNavigatedWithAction(frameElement, action) frameElement.src = location.toString() } else { this.navigator.proposeVisit(expandURL(location), options) } } refresh(url, requestId) { const isRecentRequest = requestId && this.recentRequests.has(requestId) if (!isRecentRequest) { this.visit(url, { action: 'replace', shouldCacheSnapshot: false }) } } connectStreamSource(source) { this.streamObserver.connectStreamSource(source) } disconnectStreamSource(source) { this.streamObserver.disconnectStreamSource(source) } renderStreamMessage(message) { this.streamMessageRenderer.render(StreamMessage.wrap(message)) } clearCache() { this.view.clearSnapshotCache() } setProgressBarDelay(delay) { this.progressBarDelay = delay } setFormMode(mode) { this.formMode = mode } get location() { return this.history.location } get restorationIdentifier() { return this.history.restorationIdentifier } get pageRefreshDebouncePeriod() { return this.#pageRefreshDebouncePeriod } set pageRefreshDebouncePeriod(value) { this.refresh = debounce(this.debouncedRefresh.bind(this), value) this.#pageRefreshDebouncePeriod = value } // Preloader delegate shouldPreloadLink(element) { const isUnsafe = element.hasAttribute('data-turbo-method') const isStream = element.hasAttribute('data-turbo-stream') const frameTarget = element.getAttribute('data-turbo-frame') const frame = frameTarget == '_top' ? null : document.getElementById(frameTarget) || findClosestRecursively(element, 'turbo-frame:not([disabled])') if (isUnsafe || isStream || frame instanceof FrameElement) { return false } else { const location = new URL(element.href) return this.elementIsNavigatable(element) && locationIsVisitable(location, this.snapshot.rootLocation) } } // History delegate historyPoppedToLocationWithRestorationIdentifierAndDirection(location, restorationIdentifier, direction) { if (this.enabled) { this.navigator.startVisit(location, restorationIdentifier, { action: 'restore', historyChanged: true, direction }) } else { this.adapter.pageInvalidated({ reason: 'turbo_disabled' }) } } // Scroll observer delegate scrollPositionChanged(position) { this.history.updateRestorationData({ scrollPosition: position }) } // Form click observer delegate willSubmitFormLinkToLocation(link, location) { return this.elementIsNavigatable(link) && locationIsVisitable(location, this.snapshot.rootLocation) } submittedFormLinkToLocation() {} // Link hover observer delegate canPrefetchRequestToLocation(link, location) { return this.elementIsNavigatable(link) && locationIsVisitable(location, this.snapshot.rootLocation) } // Link click observer delegate willFollowLinkToLocation(link, location, event) { return ( this.elementIsNavigatable(link) && locationIsVisitable(location, this.snapshot.rootLocation) && this.applicationAllowsFollowingLinkToLocation(link, location, event) ) } followedLinkToLocation(link, location) { const action = this.getActionForLink(link) const acceptsStreamResponse = link.hasAttribute('data-turbo-stream') this.visit(location.href, { action, acceptsStreamResponse }) } // Navigator delegate allowsVisitingLocationWithAction(location, action) { return this.locationWithActionIsSamePage(location, action) || this.applicationAllowsVisitingLocation(location) } visitProposedToLocation(location, options) { extendURLWithDeprecatedProperties(location) this.adapter.visitProposedToLocation(location, options) } // Visit delegate visitStarted(visit) { if (!visit.acceptsStreamResponse) { markAsBusy(document.documentElement) this.view.markVisitDirection(visit.direction) } extendURLWithDeprecatedProperties(visit.location) if (!visit.silent) { this.notifyApplicationAfterVisitingLocation(visit.location, visit.action) } } visitCompleted(visit) { this.view.unmarkVisitDirection() clearBusyState(document.documentElement) this.notifyApplicationAfterPageLoad(visit.getTimingMetrics()) } locationWithActionIsSamePage(location, action) { return this.navigator.locationWithActionIsSamePage(location, action) } visitScrolledToSamePageLocation(oldURL, newURL) { this.notifyApplicationAfterVisitingSamePageLocation(oldURL, newURL) } // Form submit observer delegate willSubmitForm(form, submitter) { const action = getAction$1(form, submitter) return ( this.submissionIsNavigatable(form, submitter) && locationIsVisitable(expandURL(action), this.snapshot.rootLocation) ) } formSubmitted(form, submitter) { this.navigator.submitForm(form, submitter) } // Page observer delegate pageBecameInteractive() { this.view.lastRenderedLocation = this.location this.notifyApplicationAfterPageLoad() } pageLoaded() { this.history.assumeControlOfScrollRestoration() } pageWillUnload() { this.history.relinquishControlOfScrollRestoration() } // Stream observer delegate receivedMessageFromStream(message) { this.renderStreamMessage(message) } // Page view delegate viewWillCacheSnapshot() { if (!this.navigator.currentVisit?.silent) { this.notifyApplicationBeforeCachingSnapshot() } } allowsImmediateRender({ element }, options) { const event = this.notifyApplicationBeforeRender(element, options) const { defaultPrevented, detail: { render } } = event if (this.view.renderer && render) { this.view.renderer.renderElement = render } return !defaultPrevented } viewRenderedSnapshot(_snapshot, _isPreview, renderMethod) { this.view.lastRenderedLocation = this.history.location this.notifyApplicationAfterRender(renderMethod) } preloadOnLoadLinksForView(element) { this.preloader.preloadOnLoadLinksForView(element) } viewInvalidated(reason) { this.adapter.pageInvalidated(reason) } // Frame element frameLoaded(frame) { this.notifyApplicationAfterFrameLoad(frame) } frameRendered(fetchResponse, frame) { this.notifyApplicationAfterFrameRender(fetchResponse, frame) } // Application events applicationAllowsFollowingLinkToLocation(link, location, ev) { const event = this.notifyApplicationAfterClickingLinkToLocation(link, location, ev) return !event.defaultPrevented } applicationAllowsVisitingLocation(location) { const event = this.notifyApplicationBeforeVisitingLocation(location) return !event.defaultPrevented } notifyApplicationAfterClickingLinkToLocation(link, location, event) { return dispatch('turbo:click', { target: link, detail: { url: location.href, originalEvent: event }, cancelable: true }) } notifyApplicationBeforeVisitingLocation(location) { return dispatch('turbo:before-visit', { detail: { url: location.href }, cancelable: true }) } notifyApplicationAfterVisitingLocation(location, action) { return dispatch('turbo:visit', { detail: { url: location.href, action } }) } notifyApplicationBeforeCachingSnapshot() { return dispatch('turbo:before-cache') } notifyApplicationBeforeRender(newBody, options) { return dispatch('turbo:before-render', { detail: { newBody, ...options }, cancelable: true }) } notifyApplicationAfterRender(renderMethod) { return dispatch('turbo:render', { detail: { renderMethod } }) } notifyApplicationAfterPageLoad(timing = {}) { return dispatch('turbo:load', { detail: { url: this.location.href, timing } }) } notifyApplicationAfterVisitingSamePageLocation(oldURL, newURL) { dispatchEvent( new HashChangeEvent('hashchange', { oldURL: oldURL.toString(), newURL: newURL.toString() }) ) } notifyApplicationAfterFrameLoad(frame) { return dispatch('turbo:frame-load', { target: frame }) } notifyApplicationAfterFrameRender(fetchResponse, frame) { return dispatch('turbo:frame-render', { detail: { fetchResponse }, target: frame, cancelable: true }) } // Helpers submissionIsNavigatable(form, submitter) { if (this.formMode == 'off') { return false } else { const submitterIsNavigatable = submitter ? this.elementIsNavigatable(submitter) : true if (this.formMode == 'optin') { return submitterIsNavigatable && form.closest('[data-turbo="true"]') != null } else { return submitterIsNavigatable && this.elementIsNavigatable(form) } } } elementIsNavigatable(element) { const container = findClosestRecursively(element, '[data-turbo]') const withinFrame = findClosestRecursively(element, 'turbo-frame') // Check if Drive is enabled on the session or we're within a Frame. if (this.drive || withinFrame) { // Element is navigatable by default, unless `data-turbo="false"`. if (container) { return container.getAttribute('data-turbo') != 'false' } else { return true } } else { // Element isn't navigatable by default, unless `data-turbo="true"`. if (container) { return container.getAttribute('data-turbo') == 'true' } else { return false } } } // Private getActionForLink(link) { return getVisitAction(link) || 'advance' } get snapshot() { return this.view.snapshot } } // Older versions of the Turbo Native adapters referenced the // `Location#absoluteURL` property in their implementations of // the `Adapter#visitProposedToLocation()` and `#visitStarted()` // methods. The Location class has since been removed in favor // of the DOM URL API, and accordingly all Adapter methods now // receive URL objects. // // We alias #absoluteURL to #toString() here to avoid crashing // older adapters which do not expect URL objects. We should // consider removing this support at some point in the future. function extendURLWithDeprecatedProperties(url) { Object.defineProperties(url, deprecatedLocationPropertyDescriptors) } const deprecatedLocationPropertyDescriptors = { absoluteURL: { get() { return this.toString() } } } const session = new Session(recentRequests) const { cache, navigator: navigator$1 } = session /** * Starts the main session. * This initialises any necessary observers such as those to monitor * link interactions. */ function start() { session.start() } /** * Registers an adapter for the main session. * * @param adapter Adapter to register */ function registerAdapter(adapter) { session.registerAdapter(adapter) } /** * Performs an application visit to the given location. * * @param location Location to visit (a URL or path) * @param options Options to apply * @param options.action Type of history navigation to apply ("restore", * "replace" or "advance") * @param options.historyChanged Specifies whether the browser history has * already been changed for this visit or not * @param options.referrer Specifies the referrer of this visit such that * navigations to the same page will not result in a new history entry. * @param options.snapshotHTML Cached snapshot to render * @param options.response Response of the specified location */ function visit(location, options) { session.visit(location, options) } /** * Connects a stream source to the main session. * * @param source Stream source to connect */ function connectStreamSource(source) { session.connectStreamSource(source) } /** * Disconnects a stream source from the main session. * * @param source Stream source to disconnect */ function disconnectStreamSource(source) { session.disconnectStreamSource(source) } /** * Renders a stream message to the main session by appending it to the * current document. * * @param message Message to render */ function renderStreamMessage(message) { session.renderStreamMessage(message) } /** * Removes all entries from the Turbo Drive page cache. * Call this when state has changed on the server that may affect cached pages. * * @deprecated since version 7.2.0 in favor of `Turbo.cache.clear()` */ function clearCache() { console.warn( 'Please replace `Turbo.clearCache()` with `Turbo.cache.clear()`. The top-level function is deprecated and will be removed in a future version of Turbo.`' ) session.clearCache() } /** * Sets the delay after which the progress bar will appear during navigation. * * The progress bar appears after 500ms by default. * * Note that this method has no effect when used with the iOS or Android * adapters. * * @param delay Time to delay in milliseconds */ function setProgressBarDelay(delay) { session.setProgressBarDelay(delay) } function setConfirmMethod(confirmMethod) { FormSubmission.confirmMethod = confirmMethod } function setFormMode(mode) { session.setFormMode(mode) } var Turbo = /*#__PURE__*/ Object.freeze({ __proto__: null, navigator: navigator$1, session: session, cache: cache, PageRenderer: PageRenderer, PageSnapshot: PageSnapshot, FrameRenderer: FrameRenderer, fetch: fetchWithTurboHeaders, start: start, registerAdapter: registerAdapter, visit: visit, connectStreamSource: connectStreamSource, disconnectStreamSource: disconnectStreamSource, renderStreamMessage: renderStreamMessage, clearCache: clearCache, setProgressBarDelay: setProgressBarDelay, setConfirmMethod: setConfirmMethod, setFormMode: setFormMode }) class TurboFrameMissingError extends Error {} class FrameController { fetchResponseLoaded = _fetchResponse => Promise.resolve() #currentFetchRequest = null #resolveVisitPromise = () => {} #connected = false #hasBeenLoaded = false #ignoredAttributes = new Set() action = null constructor(element) { this.element = element this.view = new FrameView(this, this.element) this.appearanceObserver = new AppearanceObserver(this, this.element) this.formLinkClickObserver = new FormLinkClickObserver(this, this.element) this.linkInterceptor = new LinkInterceptor(this, this.element) this.restorationIdentifier = uuid() this.formSubmitObserver = new FormSubmitObserver(this, this.element) } // Frame delegate connect() { if (!this.#connected) { this.#connected = true if (this.loadingStyle == FrameLoadingStyle.lazy) { this.appearanceObserver.start() } else { this.#loadSourceURL() } this.formLinkClickObserver.start() this.linkInterceptor.start() this.formSubmitObserver.start() } } disconnect() { if (this.#connected) { this.#connected = false this.appearanceObserver.stop() this.formLinkClickObserver.stop() this.linkInterceptor.stop() this.formSubmitObserver.stop() } } disabledChanged() { if (this.loadingStyle == FrameLoadingStyle.eager) { this.#loadSourceURL() } } sourceURLChanged() { if (this.#isIgnoringChangesTo('src')) return if (this.element.isConnected) { this.complete = false } if (this.loadingStyle == FrameLoadingStyle.eager || this.#hasBeenLoaded) { this.#loadSourceURL() } } sourceURLReloaded() { const { src } = this.element this.element.removeAttribute('complete') this.element.src = null this.element.src = src return this.element.loaded } loadingStyleChanged() { if (this.loadingStyle == FrameLoadingStyle.lazy) { this.appearanceObserver.start() } else { this.appearanceObserver.stop() this.#loadSourceURL() } } async #loadSourceURL() { if (this.enabled && this.isActive && !this.complete && this.sourceURL) { this.element.loaded = this.#visit(expandURL(this.sourceURL)) this.appearanceObserver.stop() await this.element.loaded this.#hasBeenLoaded = true } } async loadResponse(fetchResponse) { if (fetchResponse.redirected || (fetchResponse.succeeded && fetchResponse.isHTML)) { this.sourceURL = fetchResponse.response.url } try { const html = await fetchResponse.responseHTML if (html) { const document = parseHTMLDocument(html) const pageSnapshot = PageSnapshot.fromDocument(document) if (pageSnapshot.isVisitable) { await this.#loadFrameResponse(fetchResponse, document) } else { await this.#handleUnvisitableFrameResponse(fetchResponse) } } } finally { this.fetchResponseLoaded = () => Promise.resolve() } } // Appearance observer delegate elementAppearedInViewport(element) { this.proposeVisitIfNavigatedWithAction(element, getVisitAction(element)) this.#loadSourceURL() } // Form link click observer delegate willSubmitFormLinkToLocation(link) { return this.#shouldInterceptNavigation(link) } submittedFormLinkToLocation(link, _location, form) { const frame = this.#findFrameElement(link) if (frame) form.setAttribute('data-turbo-frame', frame.id) } // Link interceptor delegate shouldInterceptLinkClick(element, _location, _event) { return this.#shouldInterceptNavigation(element) } linkClickIntercepted(element, location) { this.#navigateFrame(element, location) } // Form submit observer delegate willSubmitForm(element, submitter) { return element.closest('turbo-frame') == this.element && this.#shouldInterceptNavigation(element, submitter) } formSubmitted(element, submitter) { if (this.formSubmission) { this.formSubmission.stop() } this.formSubmission = new FormSubmission(this, element, submitter) const { fetchRequest } = this.formSubmission this.prepareRequest(fetchRequest) this.formSubmission.start() } // Fetch request delegate prepareRequest(request) { request.headers['Turbo-Frame'] = this.id if (this.currentNavigationElement?.hasAttribute('data-turbo-stream')) { request.acceptResponseType(StreamMessage.contentType) } } requestStarted(_request) { markAsBusy(this.element) } requestPreventedHandlingResponse(_request, _response) { this.#resolveVisitPromise() } async requestSucceededWithResponse(request, response) { await this.loadResponse(response) this.#resolveVisitPromise() } async requestFailedWithResponse(request, response) { await this.loadResponse(response) this.#resolveVisitPromise() } requestErrored(request, error) { console.error(error) this.#resolveVisitPromise() } requestFinished(_request) { clearBusyState(this.element) } // Form submission delegate formSubmissionStarted({ formElement }) { markAsBusy(formElement, this.#findFrameElement(formElement)) } formSubmissionSucceededWithResponse(formSubmission, response) { const frame = this.#findFrameElement(formSubmission.formElement, formSubmission.submitter) frame.delegate.proposeVisitIfNavigatedWithAction( frame, getVisitAction(formSubmission.submitter, formSubmission.formElement, frame) ) frame.delegate.loadResponse(response) if (!formSubmission.isSafe) { session.clearCache() } } formSubmissionFailedWithResponse(formSubmission, fetchResponse) { this.element.delegate.loadResponse(fetchResponse) session.clearCache() } formSubmissionErrored(formSubmission, error) { console.error(error) } formSubmissionFinished({ formElement }) { clearBusyState(formElement, this.#findFrameElement(formElement)) } // View delegate allowsImmediateRender({ element: newFrame }, options) { const event = dispatch('turbo:before-frame-render', { target: this.element, detail: { newFrame, ...options }, cancelable: true }) const { defaultPrevented, detail: { render } } = event if (this.view.renderer && render) { this.view.renderer.renderElement = render } return !defaultPrevented } viewRenderedSnapshot(_snapshot, _isPreview, _renderMethod) {} preloadOnLoadLinksForView(element) { session.preloadOnLoadLinksForView(element) } viewInvalidated() {} // Frame renderer delegate willRenderFrame(currentElement, _newElement) { this.previousFrameElement = currentElement.cloneNode(true) } visitCachedSnapshot = ({ element }) => { const frame = element.querySelector('#' + this.element.id) if (frame && this.previousFrameElement) { frame.replaceChildren(...this.previousFrameElement.children) } delete this.previousFrameElement } // Private async #loadFrameResponse(fetchResponse, document) { const newFrameElement = await this.extractForeignFrameElement(document.body) if (newFrameElement) { const snapshot = new Snapshot(newFrameElement) const renderer = new FrameRenderer( this, this.view.snapshot, snapshot, FrameRenderer.renderElement, false, false ) if (this.view.renderPromise) await this.view.renderPromise this.changeHistory() await this.view.render(renderer) this.complete = true session.frameRendered(fetchResponse, this.element) session.frameLoaded(this.element) await this.fetchResponseLoaded(fetchResponse) } else if (this.#willHandleFrameMissingFromResponse(fetchResponse)) { this.#handleFrameMissingFromResponse(fetchResponse) } } async #visit(url) { const request = new FetchRequest(this, FetchMethod.get, url, new URLSearchParams(), this.element) this.#currentFetchRequest?.cancel() this.#currentFetchRequest = request return new Promise(resolve => { this.#resolveVisitPromise = () => { this.#resolveVisitPromise = () => {} this.#currentFetchRequest = null resolve() } request.perform() }) } #navigateFrame(element, url, submitter) { const frame = this.#findFrameElement(element, submitter) frame.delegate.proposeVisitIfNavigatedWithAction(frame, getVisitAction(submitter, element, frame)) this.#withCurrentNavigationElement(element, () => { frame.src = url }) } proposeVisitIfNavigatedWithAction(frame, action = null) { this.action = action if (this.action) { const pageSnapshot = PageSnapshot.fromElement(frame).clone() const { visitCachedSnapshot } = frame.delegate frame.delegate.fetchResponseLoaded = async fetchResponse => { if (frame.src) { const { statusCode, redirected } = fetchResponse const responseHTML = await fetchResponse.responseHTML const response = { statusCode, redirected, responseHTML } const options = { response, visitCachedSnapshot, willRender: false, updateHistory: false, restorationIdentifier: this.restorationIdentifier, snapshot: pageSnapshot } if (this.action) options.action = this.action session.visit(frame.src, options) } } } } changeHistory() { if (this.action) { const method = getHistoryMethodForAction(this.action) session.history.update(method, expandURL(this.element.src || ''), this.restorationIdentifier) } } async #handleUnvisitableFrameResponse(fetchResponse) { console.warn( `The response (${fetchResponse.statusCode}) from is performing a full page visit due to turbo-visit-control.` ) await this.#visitResponse(fetchResponse.response) } #willHandleFrameMissingFromResponse(fetchResponse) { this.element.setAttribute('complete', '') const response = fetchResponse.response const visit = async (url, options) => { if (url instanceof Response) { this.#visitResponse(url) } else { session.visit(url, options) } } const event = dispatch('turbo:frame-missing', { target: this.element, detail: { response, visit }, cancelable: true }) return !event.defaultPrevented } #handleFrameMissingFromResponse(fetchResponse) { this.view.missing() this.#throwFrameMissingError(fetchResponse) } #throwFrameMissingError(fetchResponse) { const message = `The response (${fetchResponse.statusCode}) did not contain the expected and will be ignored. To perform a full page visit instead, set turbo-visit-control to reload.` throw new TurboFrameMissingError(message) } async #visitResponse(response) { const wrapped = new FetchResponse(response) const responseHTML = await wrapped.responseHTML const { location, redirected, statusCode } = wrapped return session.visit(location, { response: { redirected, statusCode, responseHTML } }) } #findFrameElement(element, submitter) { const id = getAttribute('data-turbo-frame', submitter, element) || this.element.getAttribute('target') return getFrameElementById(id) ?? this.element } async extractForeignFrameElement(container) { let element const id = CSS.escape(this.id) try { element = activateElement(container.querySelector(`turbo-frame#${id}`), this.sourceURL) if (element) { return element } element = activateElement(container.querySelector(`turbo-frame[src][recurse~=${id}]`), this.sourceURL) if (element) { await element.loaded return await this.extractForeignFrameElement(element) } } catch (error) { console.error(error) return new FrameElement() } return null } #formActionIsVisitable(form, submitter) { const action = getAction$1(form, submitter) return locationIsVisitable(expandURL(action), this.rootLocation) } #shouldInterceptNavigation(element, submitter) { const id = getAttribute('data-turbo-frame', submitter, element) || this.element.getAttribute('target') if (element instanceof HTMLFormElement && !this.#formActionIsVisitable(element, submitter)) { return false } if (!this.enabled || id == '_top') { return false } if (id) { const frameElement = getFrameElementById(id) if (frameElement) { return !frameElement.disabled } } if (!session.elementIsNavigatable(element)) { return false } if (submitter && !session.elementIsNavigatable(submitter)) { return false } return true } // Computed properties get id() { return this.element.id } get enabled() { return !this.element.disabled } get sourceURL() { if (this.element.src) { return this.element.src } } set sourceURL(sourceURL) { this.#ignoringChangesToAttribute('src', () => { this.element.src = sourceURL ?? null }) } get loadingStyle() { return this.element.loading } get isLoading() { return this.formSubmission !== undefined || this.#resolveVisitPromise() !== undefined } get complete() { return this.element.hasAttribute('complete') } set complete(value) { if (value) { this.element.setAttribute('complete', '') } else { this.element.removeAttribute('complete') } } get isActive() { return this.element.isActive && this.#connected } get rootLocation() { const meta = this.element.ownerDocument.querySelector(`meta[name="turbo-root"]`) const root = meta?.content ?? '/' return expandURL(root) } #isIgnoringChangesTo(attributeName) { return this.#ignoredAttributes.has(attributeName) } #ignoringChangesToAttribute(attributeName, callback) { this.#ignoredAttributes.add(attributeName) callback() this.#ignoredAttributes.delete(attributeName) } #withCurrentNavigationElement(element, callback) { this.currentNavigationElement = element callback() delete this.currentNavigationElement } } function getFrameElementById(id) { if (id != null) { const element = document.getElementById(id) if (element instanceof FrameElement) { return element } } } function activateElement(element, currentURL) { if (element) { const src = element.getAttribute('src') if (src != null && currentURL != null && urlsAreEqual(src, currentURL)) { throw new Error( `Matching element has a source URL which references itself` ) } if (element.ownerDocument !== document) { element = document.importNode(element, true) } if (element instanceof FrameElement) { element.connectedCallback() element.disconnectedCallback() return element } } } const StreamActions = { after() { this.targetElements.forEach(e => e.parentElement?.insertBefore(this.templateContent, e.nextSibling)) }, append() { this.removeDuplicateTargetChildren() this.targetElements.forEach(e => e.append(this.templateContent)) }, before() { this.targetElements.forEach(e => e.parentElement?.insertBefore(this.templateContent, e)) }, prepend() { this.removeDuplicateTargetChildren() this.targetElements.forEach(e => e.prepend(this.templateContent)) }, remove() { this.targetElements.forEach(e => e.remove()) }, replace() { this.targetElements.forEach(e => e.replaceWith(this.templateContent)) }, update() { this.targetElements.forEach(targetElement => { targetElement.innerHTML = '' targetElement.append(this.templateContent) }) }, refresh() { session.refresh(this.baseURI, this.requestId) } } //