goffeetabler/storage/public/dist/libs/@hotwired/turbo/dist/turbo.es2017-esm.js
2026-05-01 18:15:40 -05:00

7256 lines
199 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*!
Turbo 8.0.23
Copyright © 2026 37signals LLC
*/
const FrameLoadingStyle = {
eager: "eager",
lazy: "lazy"
};
/**
* Contains a fragment of HTML which is updated based on navigation within
* it (e.g. via links or form submissions).
*
* @customElement turbo-frame
* @example
* <turbo-frame id="messages">
* <a href="/messages/expanded">
* Show all expanded messages in this frame.
* </a>
*
* <form action="/messages">
* Show response from this form within this frame.
* </form>
* </turbo-frame>
*/
class FrameElement extends HTMLElement {
static delegateConstructor = undefined
loaded = Promise.resolve()
static get observedAttributes() {
return ["disabled", "loading", "src"]
}
constructor() {
super();
this.delegate = new FrameElement.delegateConstructor(this);
}
connectedCallback() {
this.delegate.connect();
}
disconnectedCallback() {
this.delegate.disconnect();
}
reload() {
return this.delegate.sourceURLReloaded()
}
attributeChangedCallback(name) {
if (name == "loading") {
this.delegate.loadingStyleChanged();
} else if (name == "src") {
this.delegate.sourceURLChanged();
} else if (name == "disabled") {
this.delegate.disabledChanged();
}
}
/**
* Gets the URL to lazily load source HTML from
*/
get src() {
return this.getAttribute("src")
}
/**
* Sets the URL to lazily load source HTML from
*/
set src(value) {
if (value) {
this.setAttribute("src", value);
} else {
this.removeAttribute("src");
}
}
/**
* Gets the refresh mode for the frame.
*/
get refresh() {
return this.getAttribute("refresh")
}
/**
* Sets the refresh mode for the frame.
*/
set refresh(value) {
if (value) {
this.setAttribute("refresh", value);
} else {
this.removeAttribute("refresh");
}
}
get shouldReloadWithMorph() {
return this.src && this.refresh === "morph"
}
/**
* Determines if the element is loading
*/
get loading() {
return frameLoadingStyleFromString(this.getAttribute("loading") || "")
}
/**
* Sets the value of if the element is loading
*/
set loading(value) {
if (value) {
this.setAttribute("loading", value);
} else {
this.removeAttribute("loading");
}
}
/**
* Gets the disabled state of the frame.
*
* If disabled, no requests will be intercepted by the frame.
*/
get disabled() {
return this.hasAttribute("disabled")
}
/**
* Sets the disabled state of the frame.
*
* If disabled, no requests will be intercepted by the frame.
*/
set disabled(value) {
if (value) {
this.setAttribute("disabled", "");
} else {
this.removeAttribute("disabled");
}
}
/**
* Gets the autoscroll state of the frame.
*
* If true, the frame will be scrolled into view automatically on update.
*/
get autoscroll() {
return this.hasAttribute("autoscroll")
}
/**
* Sets the autoscroll state of the frame.
*
* If true, the frame will be scrolled into view automatically on update.
*/
set autoscroll(value) {
if (value) {
this.setAttribute("autoscroll", "");
} else {
this.removeAttribute("autoscroll");
}
}
/**
* Determines if the element has finished loading
*/
get complete() {
return !this.delegate.isLoading
}
/**
* Gets the active state of the frame.
*
* If inactive, source changes will not be observed.
*/
get isActive() {
return this.ownerDocument === document && !this.isPreview
}
/**
* Sets the active state of the frame.
*
* If inactive, source changes will not be observed.
*/
get isPreview() {
return this.ownerDocument?.documentElement?.hasAttribute("data-turbo-preview")
}
}
function frameLoadingStyleFromString(style) {
switch (style.toLowerCase()) {
case "lazy":
return FrameLoadingStyle.lazy
default:
return FrameLoadingStyle.eager
}
}
const drive = {
enabled: true,
progressBarDelay: 500,
unvisitableExtensions: new Set(
[
".7z", ".aac", ".apk", ".avi", ".bmp", ".bz2", ".css", ".csv", ".deb", ".dmg", ".doc",
".docx", ".exe", ".gif", ".gz", ".heic", ".heif", ".ico", ".iso", ".jpeg", ".jpg",
".js", ".json", ".m4a", ".mkv", ".mov", ".mp3", ".mp4", ".mpeg", ".mpg", ".msi",
".ogg", ".ogv", ".pdf", ".pkg", ".png", ".ppt", ".pptx", ".rar", ".rtf",
".svg", ".tar", ".tif", ".tiff", ".txt", ".wav", ".webm", ".webp", ".wma", ".wmv",
".xls", ".xlsx", ".xml", ".zip"
]
)
};
function activateScriptElement(element) {
if (element.getAttribute("data-turbo-eval") == "false") {
return element
} else {
const createdScriptElement = document.createElement("script");
const cspNonce = getCspNonce();
if (cspNonce) {
createdScriptElement.nonce = cspNonce;
}
createdScriptElement.textContent = element.textContent;
createdScriptElement.async = false;
copyElementAttributes(createdScriptElement, element);
return createdScriptElement
}
}
function copyElementAttributes(destinationElement, sourceElement) {
for (const { name, value } of sourceElement.attributes) {
destinationElement.setAttribute(name, value);
}
}
function createDocumentFragment(html) {
const template = document.createElement("template");
template.innerHTML = html;
return template.content
}
function dispatch(eventName, { target, cancelable, detail } = {}) {
const event = new CustomEvent(eventName, {
cancelable,
bubbles: true,
composed: true,
detail
});
if (target && target.isConnected) {
target.dispatchEvent(event);
} else {
document.documentElement.dispatchEvent(event);
}
return event
}
function cancelEvent(event) {
event.preventDefault();
event.stopImmediatePropagation();
}
function nextRepaint() {
if (document.visibilityState === "hidden") {
return nextEventLoopTick()
} else {
return nextAnimationFrame()
}
}
function nextAnimationFrame() {
return new Promise((resolve) => requestAnimationFrame(() => resolve()))
}
function nextEventLoopTick() {
return new Promise((resolve) => setTimeout(() => resolve(), 0))
}
function 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() * 16).toString(16)
}
})
.join("")
}
function getAttribute(attributeName, ...elements) {
for (const value of elements.map((element) => element?.getAttribute(attributeName))) {
if (typeof value == "string") return value
}
return null
}
function hasAttribute(attributeName, ...elements) {
return elements.some((element) => element && element.hasAttribute(attributeName))
}
function markAsBusy(...elements) {
for (const element of elements) {
if (element.localName == "turbo-frame") {
element.setAttribute("busy", "");
}
element.setAttribute("aria-busy", "true");
}
}
function clearBusyState(...elements) {
for (const element of elements) {
if (element.localName == "turbo-frame") {
element.removeAttribute("busy");
}
element.removeAttribute("aria-busy");
}
}
function waitForLoad(element, timeoutInMilliseconds = 2000) {
return new Promise((resolve) => {
const onComplete = () => {
element.removeEventListener("error", onComplete);
element.removeEventListener("load", onComplete);
resolve();
};
element.addEventListener("load", onComplete, { once: true });
element.addEventListener("error", onComplete, { once: true });
setTimeout(resolve, timeoutInMilliseconds);
})
}
function getHistoryMethodForAction(action) {
switch (action) {
case "replace":
return history.replaceState
case "advance":
case "restore":
return history.pushState
}
}
function isAction(action) {
return action == "advance" || action == "replace" || action == "restore"
}
function getVisitAction(...elements) {
const action = getAttribute("data-turbo-action", ...elements);
return isAction(action) ? action : null
}
function getMetaElement(name) {
return document.querySelector(`meta[name="${name}"]`)
}
function getMetaContent(name) {
const element = getMetaElement(name);
return element && element.content
}
function getCspNonce() {
const element = getMetaElement("csp-nonce");
if (element) {
const { nonce, content } = element;
return nonce == "" ? content : nonce
}
}
function setMetaContent(name, content) {
let element = getMetaElement(name);
if (!element) {
element = document.createElement("meta");
element.setAttribute("name", name);
document.head.appendChild(element);
}
element.setAttribute("content", content);
return element
}
function findClosestRecursively(element, selector) {
if (element instanceof Element) {
return (
element.closest(selector) || findClosestRecursively(element.assignedSlot || element.getRootNode()?.host, selector)
)
}
}
function elementIsFocusable(element) {
const inertDisabledOrHidden = "[inert], :disabled, [hidden], details:not([open]), dialog:not([open])";
return !!element && element.closest(inertDisabledOrHidden) == null && typeof element.focus == "function"
}
function queryAutofocusableElement(elementOrDocumentFragment) {
return Array.from(elementOrDocumentFragment.querySelectorAll("[autofocus]")).find(elementIsFocusable)
}
async function around(callback, reader) {
const before = reader();
callback();
await nextAnimationFrame();
const after = reader();
return [before, after]
}
function doesNotTargetIFrame(name) {
if (name === "_blank") {
return false
} else if (name) {
for (const element of document.getElementsByName(name)) {
if (element instanceof HTMLIFrameElement) return false
}
return true
} else {
return true
}
}
function findLinkFromClickTarget(target) {
const link = findClosestRecursively(target, "a[href], a[xlink\\:href]");
if (!link) return null
if (link.href.startsWith("#")) return null
if (link.hasAttribute("download")) return null
const linkTarget = link.getAttribute("target");
if (linkTarget && linkTarget !== "_self") return null
return link
}
function debounce(fn, delay) {
let timeoutId = null;
return (...args) => {
const callback = () => fn.apply(this, args);
clearTimeout(timeoutId);
timeoutId = setTimeout(callback, delay);
}
}
const submitter = {
"aria-disabled": {
beforeSubmit: submitter => {
submitter.setAttribute("aria-disabled", "true");
submitter.addEventListener("click", cancelEvent);
},
afterSubmit: submitter => {
submitter.removeAttribute("aria-disabled");
submitter.removeEventListener("click", cancelEvent);
}
},
"disabled": {
beforeSubmit: submitter => submitter.disabled = true,
afterSubmit: submitter => submitter.disabled = false
}
};
class Config {
#submitter = null
constructor(config) {
Object.assign(this, config);
}
get submitter() {
return this.#submitter
}
set submitter(value) {
this.#submitter = submitter[value] || value;
}
}
const forms = new Config({
mode: "on",
submitter: "disabled"
});
const config = {
drive,
forms
};
function expandURL(locatable) {
return new URL(locatable.toString(), document.baseURI)
}
function getAnchor(url) {
let anchorMatch;
if (url.hash) {
return url.hash.slice(1)
// eslint-disable-next-line no-cond-assign
} else if ((anchorMatch = url.href.match(/#(.*)$/))) {
return anchorMatch[1]
}
}
function getAction$1(form, submitter) {
const action = submitter?.getAttribute("formaction") || form.getAttribute("action") || form.action;
return expandURL(action)
}
function getExtension(url) {
return (getLastPathComponent(url).match(/\.[^.]*$/) || [])[0] || ""
}
function isPrefixedBy(baseURL, url) {
const prefix = addTrailingSlash(url.origin + url.pathname);
return addTrailingSlash(baseURL.href) === prefix || baseURL.href.startsWith(prefix)
}
function locationIsVisitable(location, rootLocation) {
return isPrefixedBy(location, rootLocation) && !config.drive.unvisitableExtensions.has(getExtension(location))
}
function getLocationForLink(link) {
return expandURL(link.getAttribute("href") || "")
}
function getRequestURL(url) {
const anchor = getAnchor(url);
return anchor != null ? url.href.slice(0, -(anchor.length + 1)) : url.href
}
function toCacheKey(url) {
return getRequestURL(url)
}
function urlsAreEqual(left, right) {
return expandURL(left).href == expandURL(right).href
}
function getPathComponents(url) {
return url.pathname.split("/").slice(1)
}
function getLastPathComponent(url) {
return getPathComponents(url).slice(-1)[0]
}
function addTrailingSlash(value) {
return value.endsWith("/") ? value : value + "/"
}
class FetchResponse {
constructor(response) {
this.response = response;
}
get succeeded() {
return this.response.ok
}
get failed() {
return !this.succeeded
}
get clientError() {
return this.statusCode >= 400 && this.statusCode <= 499
}
get serverError() {
return this.statusCode >= 500 && this.statusCode <= 599
}
get redirected() {
return this.response.redirected
}
get location() {
return expandURL(this.response.url)
}
get isHTML() {
return this.contentType && this.contentType.match(/^(?:text\/([^\s;,]+\b)?html|application\/xhtml\+xml)\b/)
}
get statusCode() {
return this.response.status
}
get contentType() {
return this.header("Content-Type")
}
get responseText() {
return this.response.clone().text()
}
get responseHTML() {
if (this.isHTML) {
return this.response.clone().text()
} else {
return Promise.resolve(undefined)
}
}
header(name) {
return this.response.headers.get(name)
}
}
class LimitedSet extends Set {
constructor(maxSize) {
super();
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);
function fetchWithTurboHeaders(url, options = {}) {
const modifiedHeaders = new Headers(options.headers || {});
const requestUID = uuid();
recentRequests.add(requestUID);
modifiedHeaders.append("X-Turbo-Request-Id", requestUID);
return window.fetch(url, {
...options,
headers: modifiedHeaders
})
}
function fetchMethodFromString(method) {
switch (method.toLowerCase()) {
case "get":
return FetchMethod.get
case "post":
return FetchMethod.post
case "put":
return FetchMethod.put
case "patch":
return FetchMethod.patch
case "delete":
return FetchMethod.delete
}
}
const FetchMethod = {
get: "get",
post: "post",
put: "put",
patch: "patch",
delete: "delete"
};
function fetchEnctypeFromString(encoding) {
switch (encoding.toLowerCase()) {
case FetchEnctype.multipart:
return FetchEnctype.multipart
case FetchEnctype.plain:
return FetchEnctype.plain
default:
return FetchEnctype.urlEncoded
}
}
const FetchEnctype = {
urlEncoded: "application/x-www-form-urlencoded",
multipart: "multipart/form-data",
plain: "text/plain"
};
class FetchRequest {
abortController = new AbortController()
#resolveRequestPromise = (_value) => {}
constructor(delegate, method, location, requestBody = new URLSearchParams(), target = null, enctype = FetchEnctype.urlEncoded) {
const [url, body] = buildResourceAndBody(expandURL(location), method, requestBody, enctype);
this.delegate = delegate;
this.url = url;
this.target = target;
this.fetchOptions = {
credentials: "same-origin",
redirect: "follow",
method: method.toUpperCase(),
headers: { ...this.defaultHeaders },
body: body,
signal: this.abortSignal,
referrer: this.delegate.referrer?.href
};
this.enctype = enctype;
}
get method() {
return this.fetchOptions.method
}
set method(value) {
const fetchBody = this.isSafe ? this.url.searchParams : this.fetchOptions.body || new FormData();
const fetchMethod = fetchMethodFromString(value) || FetchMethod.get;
this.url.search = "";
const [url, body] = buildResourceAndBody(this.url, fetchMethod, fetchBody, this.enctype);
this.url = url;
this.fetchOptions.body = body;
this.fetchOptions.method = fetchMethod.toUpperCase();
}
get headers() {
return this.fetchOptions.headers
}
set headers(value) {
this.fetchOptions.headers = value;
}
get body() {
if (this.isSafe) {
return this.url.searchParams
} else {
return this.fetchOptions.body
}
}
set body(value) {
this.fetchOptions.body = value;
}
get location() {
return this.url
}
get params() {
return this.url.searchParams
}
get entries() {
return this.body ? Array.from(this.body.entries()) : []
}
cancel() {
this.abortController.abort();
}
async perform() {
const { fetchOptions } = this;
this.delegate.prepareRequest(this);
const event = await this.#allowRequestToBeIntercepted(fetchOptions);
try {
this.delegate.requestStarted(this);
if (event.detail.fetchRequest) {
this.response = event.detail.fetchRequest.response;
} else {
this.response = fetchWithTurboHeaders(this.url.href, fetchOptions);
}
const response = await this.response;
return await this.receive(response)
} catch (error) {
if (error.name !== "AbortError") {
if (this.#willDelegateErrorHandling(error)) {
this.delegate.requestErrored(this, error);
}
throw error
}
} finally {
this.delegate.requestFinished(this);
}
}
async receive(response) {
const fetchResponse = new FetchResponse(response);
const event = dispatch("turbo:before-fetch-response", {
cancelable: true,
detail: { fetchResponse },
target: this.target
});
if (event.defaultPrevented) {
this.delegate.requestPreventedHandlingResponse(this, fetchResponse);
} else if (fetchResponse.succeeded) {
this.delegate.requestSucceededWithResponse(this, fetchResponse);
} else {
this.delegate.requestFailedWithResponse(this, fetchResponse);
}
return fetchResponse
}
get defaultHeaders() {
return {
Accept: "text/html, application/xhtml+xml"
}
}
get isSafe() {
return isSafe(this.method)
}
get abortSignal() {
return this.abortController.signal
}
acceptResponseType(mimeType) {
this.headers["Accept"] = [mimeType, this.headers["Accept"]].join(", ");
}
async #allowRequestToBeIntercepted(fetchOptions) {
const requestInterception = new Promise((resolve) => (this.#resolveRequestPromise = resolve));
const event = dispatch("turbo:before-fetch-request", {
cancelable: true,
detail: {
fetchOptions,
url: this.url,
resume: this.#resolveRequestPromise
},
target: this.target
});
this.url = event.detail.url;
if (event.defaultPrevented) await requestInterception;
return event
}
#willDelegateErrorHandling(error) {
const event = dispatch("turbo:fetch-request-error", {
target: this.target,
cancelable: true,
detail: { request: this, error: error }
});
return !event.defaultPrevented
}
}
function isSafe(fetchMethod) {
return fetchMethodFromString(fetchMethod) == FetchMethod.get
}
function buildResourceAndBody(resource, method, requestBody, enctype) {
const searchParams =
Array.from(requestBody).length > 0 ? new URLSearchParams(entriesExcludingFiles(requestBody)) : resource.searchParams;
if (isSafe(method)) {
return [mergeIntoURLSearchParams(resource, searchParams), null]
} else if (enctype == FetchEnctype.urlEncoded) {
return [resource, searchParams]
} else {
return [resource, requestBody]
}
}
function entriesExcludingFiles(requestBody) {
const entries = [];
for (const [name, value] of requestBody) {
if (value instanceof File) continue
else entries.push([name, value]);
}
return entries
}
function mergeIntoURLSearchParams(url, requestBody) {
const searchParams = new URLSearchParams(entriesExcludingFiles(requestBody));
url.search = searchParams.toString();
return url
}
class AppearanceObserver {
started = false
constructor(delegate, element) {
this.delegate = delegate;
this.element = element;
this.intersectionObserver = new IntersectionObserver(this.intersect);
}
start() {
if (!this.started) {
this.started = true;
this.intersectionObserver.observe(this.element);
}
}
stop() {
if (this.started) {
this.started = false;
this.intersectionObserver.unobserve(this.element);
}
}
intersect = (entries) => {
const lastEntry = entries.slice(-1)[0];
if (lastEntry?.isIntersecting) {
this.delegate.elementAppearedInViewport(this.element);
}
}
}
class StreamMessage {
static contentType = "text/vnd.turbo-stream.html"
static wrap(message) {
if (typeof message == "string") {
return new this(createDocumentFragment(message))
} else {
return message
}
}
constructor(fragment) {
this.fragment = importStreamElements(fragment);
}
}
function importStreamElements(fragment) {
for (const element of fragment.querySelectorAll("turbo-stream")) {
const streamElement = document.importNode(element, true);
for (const inertScriptElement of streamElement.templateElement.content.querySelectorAll("script")) {
inertScriptElement.replaceWith(activateScriptElement(inertScriptElement));
}
element.replaceWith(streamElement);
}
return fragment
}
const identity = key => key;
class LRUCache {
keys = []
entries = {}
#toCacheKey
constructor(size, toCacheKey = identity) {
this.size = size;
this.#toCacheKey = toCacheKey;
}
has(key) {
return this.#toCacheKey(key) in this.entries
}
get(key) {
if (this.has(key)) {
const entry = this.read(key);
this.touch(key);
return entry
}
}
put(key, entry) {
this.write(key, entry);
this.touch(key);
return entry
}
clear() {
for (const key of Object.keys(this.entries)) {
this.evict(key);
}
}
// Private
read(key) {
return this.entries[this.#toCacheKey(key)]
}
write(key, entry) {
this.entries[this.#toCacheKey(key)] = entry;
}
touch(key) {
key = this.#toCacheKey(key);
const index = this.keys.indexOf(key);
if (index > -1) this.keys.splice(index, 1);
this.keys.unshift(key);
this.trim();
}
trim() {
for (const key of this.keys.splice(this.size)) {
this.evict(key);
}
}
evict(key) {
delete this.entries[key];
}
}
const PREFETCH_DELAY = 100;
class PrefetchCache extends LRUCache {
#prefetchTimeout = null
#maxAges = {}
constructor(size = 1, prefetchDelay = PREFETCH_DELAY) {
super(size, toCacheKey);
this.prefetchDelay = prefetchDelay;
}
putLater(url, request, ttl) {
this.#prefetchTimeout = setTimeout(() => {
request.perform();
this.put(url, request, ttl);
this.#prefetchTimeout = null;
}, this.prefetchDelay);
}
put(url, request, ttl = cacheTtl) {
super.put(url, request);
this.#maxAges[toCacheKey(url)] = new Date(new Date().getTime() + ttl);
}
clear() {
super.clear();
if (this.#prefetchTimeout) clearTimeout(this.#prefetchTimeout);
}
evict(key) {
super.evict(key);
delete this.#maxAges[key];
}
has(key) {
if (super.has(key)) {
const maxAge = this.#maxAges[toCacheKey(key)];
return maxAge && maxAge > Date.now()
} else {
return false
}
}
}
const cacheTtl = 10 * 1000;
const prefetchCache = new PrefetchCache();
const FormSubmissionState = {
initialized: "initialized",
requesting: "requesting",
waiting: "waiting",
receiving: "receiving",
stopping: "stopping",
stopped: "stopped"
};
class FormSubmission {
state = FormSubmissionState.initialized
static confirmMethod(message) {
return Promise.resolve(confirm(message))
}
constructor(delegate, formElement, submitter, mustRedirect = false) {
const method = getMethod(formElement, submitter);
const action = getAction(getFormAction(formElement, submitter), method);
const body = buildFormData(formElement, submitter);
const enctype = getEnctype(formElement, submitter);
this.delegate = delegate;
this.formElement = formElement;
this.submitter = submitter;
this.fetchRequest = new FetchRequest(this, method, action, body, formElement, enctype);
this.mustRedirect = mustRedirect;
}
get method() {
return this.fetchRequest.method
}
set method(value) {
this.fetchRequest.method = value;
}
get action() {
return this.fetchRequest.url.toString()
}
set action(value) {
this.fetchRequest.url = expandURL(value);
}
get body() {
return this.fetchRequest.body
}
get enctype() {
return this.fetchRequest.enctype
}
get isSafe() {
return this.fetchRequest.isSafe
}
get location() {
return this.fetchRequest.url
}
// The submission process
async start() {
const { initialized, requesting } = FormSubmissionState;
const confirmationMessage = getAttribute("data-turbo-confirm", this.submitter, this.formElement);
if (typeof confirmationMessage === "string") {
const confirmMethod = typeof config.forms.confirm === "function" ?
config.forms.confirm :
FormSubmission.confirmMethod;
const answer = await confirmMethod(confirmationMessage, this.formElement, this.submitter);
if (!answer) {
return
}
}
if (this.state == initialized) {
this.state = requesting;
return this.fetchRequest.perform()
}
}
stop() {
const { stopping, stopped } = FormSubmissionState;
if (this.state != stopping && this.state != stopped) {
this.state = stopping;
this.fetchRequest.cancel();
return true
}
}
// Fetch request delegate
prepareRequest(request) {
if (!request.isSafe) {
const token = getCookieValue(getMetaContent("csrf-param")) || getMetaContent("csrf-token");
if (token) {
request.headers["X-CSRF-Token"] = token;
}
}
if (this.requestAcceptsTurboStreamResponse(request)) {
request.acceptResponseType(StreamMessage.contentType);
}
}
requestStarted(_request) {
this.state = FormSubmissionState.waiting;
if (this.submitter) config.forms.submitter.beforeSubmit(this.submitter);
this.setSubmitsWith();
markAsBusy(this.formElement);
dispatch("turbo:submit-start", {
target: this.formElement,
detail: { formSubmission: this }
});
this.delegate.formSubmissionStarted(this);
}
requestPreventedHandlingResponse(request, response) {
prefetchCache.clear();
this.result = { success: response.succeeded, fetchResponse: response };
}
requestSucceededWithResponse(request, response) {
if (response.clientError || response.serverError) {
this.delegate.formSubmissionFailedWithResponse(this, response);
return
}
prefetchCache.clear();
if (this.requestMustRedirect(request) && responseSucceededWithoutRedirect(response)) {
const error = new Error("Form responses must redirect to another location");
this.delegate.formSubmissionErrored(this, error);
} else {
this.state = FormSubmissionState.receiving;
this.result = { success: true, fetchResponse: response };
this.delegate.formSubmissionSucceededWithResponse(this, response);
}
}
requestFailedWithResponse(request, response) {
this.result = { success: false, fetchResponse: response };
this.delegate.formSubmissionFailedWithResponse(this, response);
}
requestErrored(request, error) {
this.result = { success: false, error };
this.delegate.formSubmissionErrored(this, error);
}
requestFinished(_request) {
this.state = FormSubmissionState.stopped;
if (this.submitter) config.forms.submitter.afterSubmit(this.submitter);
this.resetSubmitterText();
clearBusyState(this.formElement);
dispatch("turbo:submit-end", {
target: this.formElement,
detail: { formSubmission: this, ...this.result }
});
this.delegate.formSubmissionFinished(this);
}
// Private
setSubmitsWith() {
if (!this.submitter || !this.submitsWith) return
if (this.submitter.matches("button")) {
this.originalSubmitText = this.submitter.innerHTML;
this.submitter.innerHTML = this.submitsWith;
} else if (this.submitter.matches("input")) {
const input = this.submitter;
this.originalSubmitText = input.value;
input.value = this.submitsWith;
}
}
resetSubmitterText() {
if (!this.submitter || !this.originalSubmitText) return
if (this.submitter.matches("button")) {
this.submitter.innerHTML = this.originalSubmitText;
} else if (this.submitter.matches("input")) {
const input = this.submitter;
input.value = this.originalSubmitText;
}
}
requestMustRedirect(request) {
return !request.isSafe && this.mustRedirect
}
requestAcceptsTurboStreamResponse(request) {
return !request.isSafe || hasAttribute("data-turbo-stream", this.submitter, this.formElement)
}
get submitsWith() {
return this.submitter?.getAttribute("data-turbo-submits-with")
}
}
function buildFormData(formElement, submitter) {
const formData = new FormData(formElement);
const name = submitter?.getAttribute("name");
const value = submitter?.getAttribute("value");
if (name) {
formData.append(name, value || "");
}
return formData
}
function getCookieValue(cookieName) {
if (cookieName != null) {
const cookies = document.cookie ? document.cookie.split("; ") : [];
const cookie = cookies.find((cookie) => cookie.startsWith(cookieName));
if (cookie) {
const value = cookie.split("=").slice(1).join("=");
return value ? decodeURIComponent(value) : undefined
}
}
}
function responseSucceededWithoutRedirect(response) {
return response.statusCode == 200 && !response.redirected
}
function getFormAction(formElement, submitter) {
const formElementAction = typeof formElement.action === "string" ? formElement.action : null;
if (submitter?.hasAttribute("formaction")) {
return submitter.getAttribute("formaction") || ""
} else {
return formElement.getAttribute("action") || formElementAction || ""
}
}
function getAction(formAction, fetchMethod) {
const action = expandURL(formAction);
if (isSafe(fetchMethod)) {
action.search = "";
}
return action
}
function getMethod(formElement, submitter) {
const method = submitter?.getAttribute("formmethod") || formElement.getAttribute("method") || "";
return fetchMethodFromString(method.toLowerCase()) || FetchMethod.get
}
function getEnctype(formElement, submitter) {
return fetchEnctypeFromString(submitter?.getAttribute("formenctype") || formElement.enctype)
}
class Snapshot {
constructor(element) {
this.element = element;
}
get activeElement() {
return this.element.ownerDocument.activeElement
}
get children() {
return [...this.element.children]
}
hasAnchor(anchor) {
return this.getElementForAnchor(anchor) != null
}
getElementForAnchor(anchor) {
return anchor ? this.element.querySelector(`[id='${anchor}'], a[name='${anchor}']`) : null
}
get isConnected() {
return this.element.isConnected
}
get firstAutofocusableElement() {
return queryAutofocusableElement(this.element)
}
get permanentElements() {
return queryPermanentElementsAll(this.element)
}
getPermanentElementById(id) {
return getPermanentElementById(this.element, id)
}
getPermanentElementMapForSnapshot(snapshot) {
const permanentElementMap = {};
for (const currentPermanentElement of this.permanentElements) {
const { id } = currentPermanentElement;
const newPermanentElement = snapshot.getPermanentElementById(id);
if (newPermanentElement) {
permanentElementMap[id] = [currentPermanentElement, newPermanentElement];
}
}
return permanentElementMap
}
}
function getPermanentElementById(node, id) {
return node.querySelector(`#${id}[data-turbo-permanent]`)
}
function queryPermanentElementsAll(node) {
return node.querySelectorAll("[id][data-turbo-permanent]")
}
class FormSubmitObserver {
started = false
constructor(delegate, eventTarget) {
this.delegate = delegate;
this.eventTarget = eventTarget;
}
start() {
if (!this.started) {
this.eventTarget.addEventListener("submit", this.submitCaptured, true);
this.started = true;
}
}
stop() {
if (this.started) {
this.eventTarget.removeEventListener("submit", this.submitCaptured, true);
this.started = false;
}
}
submitCaptured = () => {
this.eventTarget.removeEventListener("submit", this.submitBubbled, false);
this.eventTarget.addEventListener("submit", this.submitBubbled, false);
}
submitBubbled = (event) => {
if (!event.defaultPrevented) {
const form = event.target instanceof HTMLFormElement ? event.target : undefined;
const submitter = event.submitter || undefined;
if (
form &&
submissionDoesNotDismissDialog(form, submitter) &&
submissionDoesNotTargetIFrame(form, submitter) &&
this.delegate.willSubmitForm(form, submitter)
) {
event.preventDefault();
event.stopImmediatePropagation();
this.delegate.formSubmitted(form, submitter);
}
}
}
}
function submissionDoesNotDismissDialog(form, submitter) {
const method = submitter?.getAttribute("formmethod") || form.getAttribute("method");
return method != "dialog"
}
function submissionDoesNotTargetIFrame(form, submitter) {
const target = submitter?.getAttribute("formtarget") || form.getAttribute("target");
return doesNotTargetIFrame(target)
}
class View {
#resolveRenderPromise = (_value) => {}
#resolveInterceptionPromise = (_value) => {}
constructor(delegate, element) {
this.delegate = delegate;
this.element = element;
}
// Scrolling
scrollToAnchor(anchor) {
const element = this.snapshot.getElementForAnchor(anchor);
if (element) {
this.focusElement(element);
this.scrollToElement(element);
} else {
this.scrollToPosition({ x: 0, y: 0 });
}
}
scrollToAnchorFromLocation(location) {
this.scrollToAnchor(getAnchor(location));
}
scrollToElement(element) {
element.scrollIntoView();
}
focusElement(element) {
if (element instanceof HTMLElement) {
if (element.hasAttribute("tabindex")) {
element.focus();
} else {
element.setAttribute("tabindex", "-1");
element.focus();
element.removeAttribute("tabindex");
}
}
}
scrollToPosition({ x, y }) {
this.scrollRoot.scrollTo(x, y);
}
scrollToTop() {
this.scrollToPosition({ x: 0, y: 0 });
}
get scrollRoot() {
return window
}
// Rendering
async render(renderer) {
const { isPreview, shouldRender, willRender, newSnapshot: snapshot } = renderer;
// A workaround to ignore tracked element mismatch reloads when performing
// a promoted Visit from a frame navigation
const shouldInvalidate = willRender;
if (shouldRender) {
try {
this.renderPromise = new Promise((resolve) => (this.#resolveRenderPromise = resolve));
this.renderer = renderer;
await this.prepareToRenderSnapshot(renderer);
const renderInterception = new Promise((resolve) => (this.#resolveInterceptionPromise = resolve));
const options = { resume: this.#resolveInterceptionPromise, render: this.renderer.renderElement, renderMethod: this.renderer.renderMethod };
const immediateRender = this.delegate.allowsImmediateRender(snapshot, options);
if (!immediateRender) await renderInterception;
await this.renderSnapshot(renderer);
this.delegate.viewRenderedSnapshot(snapshot, isPreview, this.renderer.renderMethod);
this.delegate.preloadOnLoadLinksForView(this.element);
this.finishRenderingSnapshot(renderer);
} finally {
delete this.renderer;
this.#resolveRenderPromise(undefined);
delete this.renderPromise;
}
} else if (shouldInvalidate) {
this.invalidate(renderer.reloadReason);
}
}
invalidate(reason) {
this.delegate.viewInvalidated(reason);
}
async prepareToRenderSnapshot(renderer) {
this.markAsPreview(renderer.isPreview);
await renderer.prepareToRender();
}
markAsPreview(isPreview) {
if (isPreview) {
this.element.setAttribute("data-turbo-preview", "");
} else {
this.element.removeAttribute("data-turbo-preview");
}
}
markVisitDirection(direction) {
this.element.setAttribute("data-turbo-visit-direction", direction);
}
unmarkVisitDirection() {
this.element.removeAttribute("data-turbo-visit-direction");
}
async renderSnapshot(renderer) {
await renderer.render();
}
finishRenderingSnapshot(renderer) {
renderer.finishRendering();
}
}
class FrameView extends View {
missing() {
this.element.innerHTML = `<strong class="turbo-frame-error">Content missing</strong>`;
}
get snapshot() {
return new Snapshot(this.element)
}
}
class LinkInterceptor {
constructor(delegate, element) {
this.delegate = delegate;
this.element = element;
}
start() {
this.element.addEventListener("click", this.clickBubbled);
document.addEventListener("turbo:click", this.linkClicked);
document.addEventListener("turbo:before-visit", this.willVisit);
}
stop() {
this.element.removeEventListener("click", this.clickBubbled);
document.removeEventListener("turbo:click", this.linkClicked);
document.removeEventListener("turbo:before-visit", this.willVisit);
}
clickBubbled = (event) => {
if (this.clickEventIsSignificant(event)) {
this.clickEvent = event;
} else {
delete this.clickEvent;
}
}
linkClicked = (event) => {
if (this.clickEvent && this.clickEventIsSignificant(event)) {
if (this.delegate.shouldInterceptLinkClick(event.target, event.detail.url, event.detail.originalEvent)) {
this.clickEvent.preventDefault();
event.preventDefault();
this.delegate.linkClickIntercepted(event.target, event.detail.url, event.detail.originalEvent);
}
}
delete this.clickEvent;
}
willVisit = (_event) => {
delete this.clickEvent;
}
clickEventIsSignificant(event) {
const target = event.composed ? event.target?.parentElement : event.target;
const element = findLinkFromClickTarget(target) || target;
return element instanceof Element && element.closest("turbo-frame, html") == this.element
}
}
class LinkClickObserver {
started = false
constructor(delegate, eventTarget) {
this.delegate = delegate;
this.eventTarget = eventTarget;
}
start() {
if (!this.started) {
this.eventTarget.addEventListener("click", this.clickCaptured, true);
this.started = true;
}
}
stop() {
if (this.started) {
this.eventTarget.removeEventListener("click", this.clickCaptured, true);
this.started = false;
}
}
clickCaptured = () => {
this.eventTarget.removeEventListener("click", this.clickBubbled, false);
this.eventTarget.addEventListener("click", this.clickBubbled, false);
}
clickBubbled = (event) => {
if (event instanceof MouseEvent && this.clickEventIsSignificant(event)) {
const target = (event.composedPath && event.composedPath()[0]) || event.target;
const link = findLinkFromClickTarget(target);
if (link && doesNotTargetIFrame(link.target)) {
const location = getLocationForLink(link);
if (this.delegate.willFollowLinkToLocation(link, location, event)) {
event.preventDefault();
this.delegate.followedLinkToLocation(link, location);
}
}
}
}
clickEventIsSignificant(event) {
return !(
(event.target && event.target.isContentEditable) ||
event.defaultPrevented ||
event.which > 1 ||
event.altKey ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey
)
}
}
class FormLinkClickObserver {
constructor(delegate, element) {
this.delegate = delegate;
this.linkInterceptor = new LinkClickObserver(this, element);
}
start() {
this.linkInterceptor.start();
}
stop() {
this.linkInterceptor.stop();
}
// Link hover observer delegate
canPrefetchRequestToLocation(link, location) {
return false
}
prefetchAndCacheRequestToLocation(link, location) {
return
}
// Link click observer delegate
willFollowLinkToLocation(link, location, originalEvent) {
return (
this.delegate.willSubmitFormLinkToLocation(link, location, originalEvent) &&
(link.hasAttribute("data-turbo-method") || link.hasAttribute("data-turbo-stream"))
)
}
followedLinkToLocation(link, location) {
const form = document.createElement("form");
const type = "hidden";
for (const [name, value] of location.searchParams) {
form.append(Object.assign(document.createElement("input"), { type, name, value }));
}
const action = Object.assign(location, { search: "" });
form.setAttribute("data-turbo", "true");
form.setAttribute("action", action.href);
form.setAttribute("hidden", "");
const method = link.getAttribute("data-turbo-method");
if (method) form.setAttribute("method", method);
const turboFrame = link.getAttribute("data-turbo-frame");
if (turboFrame) form.setAttribute("data-turbo-frame", turboFrame);
const turboAction = getVisitAction(link);
if (turboAction) form.setAttribute("data-turbo-action", turboAction);
const turboConfirm = link.getAttribute("data-turbo-confirm");
if (turboConfirm) form.setAttribute("data-turbo-confirm", turboConfirm);
const turboStream = link.hasAttribute("data-turbo-stream");
if (turboStream) form.setAttribute("data-turbo-stream", "");
this.delegate.submittedFormLinkToLocation(link, location, form);
document.body.appendChild(form);
form.addEventListener("turbo:submit-end", () => form.remove(), { once: true });
requestAnimationFrame(() => form.requestSubmit());
}
}
class Bardo {
static async preservingPermanentElements(delegate, permanentElementMap, callback) {
const bardo = new this(delegate, permanentElementMap);
bardo.enter();
await callback();
bardo.leave();
}
constructor(delegate, permanentElementMap) {
this.delegate = delegate;
this.permanentElementMap = permanentElementMap;
}
enter() {
for (const id in this.permanentElementMap) {
const [currentPermanentElement, newPermanentElement] = this.permanentElementMap[id];
this.delegate.enteringBardo(currentPermanentElement, newPermanentElement);
this.replaceNewPermanentElementWithPlaceholder(newPermanentElement);
}
}
leave() {
for (const id in this.permanentElementMap) {
const [currentPermanentElement] = this.permanentElementMap[id];
this.replaceCurrentPermanentElementWithClone(currentPermanentElement);
this.replacePlaceholderWithPermanentElement(currentPermanentElement);
this.delegate.leavingBardo(currentPermanentElement);
}
}
replaceNewPermanentElementWithPlaceholder(permanentElement) {
const placeholder = createPlaceholderForPermanentElement(permanentElement);
permanentElement.replaceWith(placeholder);
}
replaceCurrentPermanentElementWithClone(permanentElement) {
const clone = permanentElement.cloneNode(true);
permanentElement.replaceWith(clone);
}
replacePlaceholderWithPermanentElement(permanentElement) {
const placeholder = this.getPlaceholderById(permanentElement.id);
placeholder?.replaceWith(permanentElement);
}
getPlaceholderById(id) {
return this.placeholders.find((element) => element.content == id)
}
get placeholders() {
return [...document.querySelectorAll("meta[name=turbo-permanent-placeholder][content]")]
}
}
function createPlaceholderForPermanentElement(permanentElement) {
const element = document.createElement("meta");
element.setAttribute("name", "turbo-permanent-placeholder");
element.setAttribute("content", permanentElement.id);
return element
}
class Renderer {
#activeElement = null
static renderElement(currentElement, newElement) {
// Abstract method
}
constructor(currentSnapshot, newSnapshot, isPreview, willRender = true) {
this.currentSnapshot = currentSnapshot;
this.newSnapshot = newSnapshot;
this.isPreview = isPreview;
this.willRender = willRender;
this.renderElement = this.constructor.renderElement;
this.promise = new Promise((resolve, reject) => (this.resolvingFunctions = { resolve, reject }));
}
get shouldRender() {
return true
}
get shouldAutofocus() {
return true
}
get reloadReason() {
return
}
prepareToRender() {
return
}
render() {
// Abstract method
}
finishRendering() {
if (this.resolvingFunctions) {
this.resolvingFunctions.resolve();
delete this.resolvingFunctions;
}
}
async preservingPermanentElements(callback) {
await Bardo.preservingPermanentElements(this, this.permanentElementMap, callback);
}
focusFirstAutofocusableElement() {
if (this.shouldAutofocus) {
const element = this.connectedSnapshot.firstAutofocusableElement;
if (element) {
element.focus();
}
}
}
// Bardo delegate
enteringBardo(currentPermanentElement) {
if (this.#activeElement) return
if (currentPermanentElement.contains(this.currentSnapshot.activeElement)) {
this.#activeElement = this.currentSnapshot.activeElement;
}
}
leavingBardo(currentPermanentElement) {
if (currentPermanentElement.contains(this.#activeElement) && this.#activeElement instanceof HTMLElement) {
this.#activeElement.focus();
this.#activeElement = null;
}
}
get connectedSnapshot() {
return this.newSnapshot.isConnected ? this.newSnapshot : this.currentSnapshot
}
get currentElement() {
return this.currentSnapshot.element
}
get newElement() {
return this.newSnapshot.element
}
get permanentElementMap() {
return this.currentSnapshot.getPermanentElementMapForSnapshot(this.newSnapshot)
}
get renderMethod() {
return "replace"
}
}
class FrameRenderer extends Renderer {
static renderElement(currentElement, newElement) {
const destinationRange = document.createRange();
destinationRange.selectNodeContents(currentElement);
destinationRange.deleteContents();
const frameElement = newElement;
const sourceRange = frameElement.ownerDocument?.createRange();
if (sourceRange) {
sourceRange.selectNodeContents(frameElement);
currentElement.appendChild(sourceRange.extractContents());
}
}
constructor(delegate, currentSnapshot, newSnapshot, renderElement, isPreview, willRender = true) {
super(currentSnapshot, newSnapshot, renderElement, isPreview, willRender);
this.delegate = delegate;
}
get shouldRender() {
return true
}
async render() {
await nextRepaint();
this.preservingPermanentElements(() => {
this.loadFrameElement();
});
this.scrollFrameIntoView();
await nextRepaint();
this.focusFirstAutofocusableElement();
await nextRepaint();
this.activateScriptElements();
}
loadFrameElement() {
this.delegate.willRenderFrame(this.currentElement, this.newElement);
this.renderElement(this.currentElement, this.newElement);
}
scrollFrameIntoView() {
if (this.currentElement.autoscroll || this.newElement.autoscroll) {
const element = this.currentElement.firstElementChild;
const block = readScrollLogicalPosition(this.currentElement.getAttribute("data-autoscroll-block"), "end");
const behavior = readScrollBehavior(this.currentElement.getAttribute("data-autoscroll-behavior"), "auto");
if (element) {
element.scrollIntoView({ block, behavior });
return true
}
}
return false
}
activateScriptElements() {
for (const inertScriptElement of this.newScriptElements) {
const activatedScriptElement = activateScriptElement(inertScriptElement);
inertScriptElement.replaceWith(activatedScriptElement);
}
}
get newScriptElements() {
return this.currentElement.querySelectorAll("script")
}
}
function readScrollLogicalPosition(value, defaultValue) {
if (value == "end" || value == "start" || value == "center" || value == "nearest") {
return value
} else {
return defaultValue
}
}
function readScrollBehavior(value, defaultValue) {
if (value == "auto" || value == "smooth") {
return value
} else {
return defaultValue
}
}
/**
* @typedef {object} ConfigHead
*
* @property {'merge' | 'append' | 'morph' | 'none'} [style]
* @property {boolean} [block]
* @property {boolean} [ignore]
* @property {function(Element): boolean} [shouldPreserve]
* @property {function(Element): boolean} [shouldReAppend]
* @property {function(Element): boolean} [shouldRemove]
* @property {function(Element, {added: Node[], kept: Element[], removed: Element[]}): void} [afterHeadMorphed]
*/
/**
* @typedef {object} ConfigCallbacks
*
* @property {function(Node): boolean} [beforeNodeAdded]
* @property {function(Node): void} [afterNodeAdded]
* @property {function(Element, Node): boolean} [beforeNodeMorphed]
* @property {function(Element, Node): void} [afterNodeMorphed]
* @property {function(Element): boolean} [beforeNodeRemoved]
* @property {function(Element): void} [afterNodeRemoved]
* @property {function(string, Element, "update" | "remove"): boolean} [beforeAttributeUpdated]
*/
/**
* @typedef {object} Config
*
* @property {'outerHTML' | 'innerHTML'} [morphStyle]
* @property {boolean} [ignoreActive]
* @property {boolean} [ignoreActiveValue]
* @property {boolean} [restoreFocus]
* @property {ConfigCallbacks} [callbacks]
* @property {ConfigHead} [head]
*/
/**
* @typedef {function} NoOp
*
* @returns {void}
*/
/**
* @typedef {object} ConfigHeadInternal
*
* @property {'merge' | 'append' | 'morph' | 'none'} style
* @property {boolean} [block]
* @property {boolean} [ignore]
* @property {(function(Element): boolean) | NoOp} shouldPreserve
* @property {(function(Element): boolean) | NoOp} shouldReAppend
* @property {(function(Element): boolean) | NoOp} shouldRemove
* @property {(function(Element, {added: Node[], kept: Element[], removed: Element[]}): void) | NoOp} afterHeadMorphed
*/
/**
* @typedef {object} ConfigCallbacksInternal
*
* @property {(function(Node): boolean) | NoOp} beforeNodeAdded
* @property {(function(Node): void) | NoOp} afterNodeAdded
* @property {(function(Node, Node): boolean) | NoOp} beforeNodeMorphed
* @property {(function(Node, Node): void) | NoOp} afterNodeMorphed
* @property {(function(Node): boolean) | NoOp} beforeNodeRemoved
* @property {(function(Node): void) | NoOp} afterNodeRemoved
* @property {(function(string, Element, "update" | "remove"): boolean) | NoOp} beforeAttributeUpdated
*/
/**
* @typedef {object} ConfigInternal
*
* @property {'outerHTML' | 'innerHTML'} morphStyle
* @property {boolean} [ignoreActive]
* @property {boolean} [ignoreActiveValue]
* @property {boolean} [restoreFocus]
* @property {ConfigCallbacksInternal} callbacks
* @property {ConfigHeadInternal} head
*/
/**
* @typedef {Object} IdSets
* @property {Set<string>} persistentIds
* @property {Map<Node, Set<string>>} idMap
*/
/**
* @typedef {Function} Morph
*
* @param {Element | Document} oldNode
* @param {Element | Node | HTMLCollection | Node[] | string | null} newContent
* @param {Config} [config]
* @returns {undefined | Node[]}
*/
// base IIFE to define idiomorph
/**
*
* @type {{defaults: ConfigInternal, morph: Morph}}
*/
var Idiomorph = (function () {
/**
* @typedef {object} MorphContext
*
* @property {Element} target
* @property {Element} newContent
* @property {ConfigInternal} config
* @property {ConfigInternal['morphStyle']} morphStyle
* @property {ConfigInternal['ignoreActive']} ignoreActive
* @property {ConfigInternal['ignoreActiveValue']} ignoreActiveValue
* @property {ConfigInternal['restoreFocus']} restoreFocus
* @property {Map<Node, Set<string>>} idMap
* @property {Set<string>} persistentIds
* @property {ConfigInternal['callbacks']} callbacks
* @property {ConfigInternal['head']} head
* @property {HTMLDivElement} pantry
* @property {Element[]} activeElementAndParents
*/
//=============================================================================
// AND NOW IT BEGINS...
//=============================================================================
const noOp = () => {};
/**
* Default configuration values, updatable by users now
* @type {ConfigInternal}
*/
const defaults = {
morphStyle: "outerHTML",
callbacks: {
beforeNodeAdded: noOp,
afterNodeAdded: noOp,
beforeNodeMorphed: noOp,
afterNodeMorphed: noOp,
beforeNodeRemoved: noOp,
afterNodeRemoved: noOp,
beforeAttributeUpdated: noOp,
},
head: {
style: "merge",
shouldPreserve: (elt) => elt.getAttribute("im-preserve") === "true",
shouldReAppend: (elt) => elt.getAttribute("im-re-append") === "true",
shouldRemove: noOp,
afterHeadMorphed: noOp,
},
restoreFocus: true,
};
/**
* Core idiomorph function for morphing one DOM tree to another
*
* @param {Element | Document} oldNode
* @param {Element | Node | HTMLCollection | Node[] | string | null} newContent
* @param {Config} [config]
* @returns {Promise<Node[]> | Node[]}
*/
function morph(oldNode, newContent, config = {}) {
oldNode = normalizeElement(oldNode);
const newNode = normalizeParent(newContent);
const ctx = createMorphContext(oldNode, newNode, config);
const morphedNodes = saveAndRestoreFocus(ctx, () => {
return withHeadBlocking(
ctx,
oldNode,
newNode,
/** @param {MorphContext} ctx */ (ctx) => {
if (ctx.morphStyle === "innerHTML") {
morphChildren(ctx, oldNode, newNode);
return Array.from(oldNode.childNodes);
} else {
return morphOuterHTML(ctx, oldNode, newNode);
}
},
);
});
ctx.pantry.remove();
return morphedNodes;
}
/**
* Morph just the outerHTML of the oldNode to the newContent
* We have to be careful because the oldNode could have siblings which need to be untouched
* @param {MorphContext} ctx
* @param {Element} oldNode
* @param {Element} newNode
* @returns {Node[]}
*/
function morphOuterHTML(ctx, oldNode, newNode) {
const oldParent = normalizeParent(oldNode);
morphChildren(
ctx,
oldParent,
newNode,
// these two optional params are the secret sauce
oldNode, // start point for iteration
oldNode.nextSibling, // end point for iteration
);
// this is safe even with siblings, because normalizeParent returns a SlicedParentNode if needed.
return Array.from(oldParent.childNodes);
}
/**
* @param {MorphContext} ctx
* @param {Function} fn
* @returns {Promise<Node[]> | Node[]}
*/
function saveAndRestoreFocus(ctx, fn) {
if (!ctx.config.restoreFocus) return fn();
let activeElement =
/** @type {HTMLInputElement|HTMLTextAreaElement|null} */ (
document.activeElement
);
// don't bother if the active element is not an input or textarea
if (
!(
activeElement instanceof HTMLInputElement ||
activeElement instanceof HTMLTextAreaElement
)
) {
return fn();
}
const { id: activeElementId, selectionStart, selectionEnd } = activeElement;
const results = fn();
if (
activeElementId &&
activeElementId !== document.activeElement?.getAttribute("id")
) {
activeElement = ctx.target.querySelector(`[id="${activeElementId}"]`);
activeElement?.focus();
}
if (activeElement && !activeElement.selectionEnd && selectionEnd) {
activeElement.setSelectionRange(selectionStart, selectionEnd);
}
return results;
}
const morphChildren = (function () {
/**
* This is the core algorithm for matching up children. The idea is to use id sets to try to match up
* nodes as faithfully as possible. We greedily match, which allows us to keep the algorithm fast, but
* by using id sets, we are able to better match up with content deeper in the DOM.
*
* Basic algorithm:
* - for each node in the new content:
* - search self and siblings for an id set match, falling back to a soft match
* - if match found
* - remove any nodes up to the match:
* - pantry persistent nodes
* - delete the rest
* - morph the match
* - elsif no match found, and node is persistent
* - find its match by querying the old root (future) and pantry (past)
* - move it and its children here
* - morph it
* - else
* - create a new node from scratch as a last result
*
* @param {MorphContext} ctx the merge context
* @param {Element} oldParent the old content that we are merging the new content into
* @param {Element} newParent the parent element of the new content
* @param {Node|null} [insertionPoint] the point in the DOM we start morphing at (defaults to first child)
* @param {Node|null} [endPoint] the point in the DOM we stop morphing at (defaults to after last child)
*/
function morphChildren(
ctx,
oldParent,
newParent,
insertionPoint = null,
endPoint = null,
) {
// normalize
if (
oldParent instanceof HTMLTemplateElement &&
newParent instanceof HTMLTemplateElement
) {
// @ts-ignore we can pretend the DocumentFragment is an Element
oldParent = oldParent.content;
// @ts-ignore ditto
newParent = newParent.content;
}
insertionPoint ||= oldParent.firstChild;
// run through all the new content
for (const newChild of newParent.childNodes) {
// once we reach the end of the old parent content skip to the end and insert the rest
if (insertionPoint && insertionPoint != endPoint) {
const bestMatch = findBestMatch(
ctx,
newChild,
insertionPoint,
endPoint,
);
if (bestMatch) {
// if the node to morph is not at the insertion point then remove/move up to it
if (bestMatch !== insertionPoint) {
removeNodesBetween(ctx, insertionPoint, bestMatch);
}
morphNode(bestMatch, newChild, ctx);
insertionPoint = bestMatch.nextSibling;
continue;
}
}
// if the matching node is elsewhere in the original content
if (newChild instanceof Element) {
// we can pretend the id is non-null because the next `.has` line will reject it if not
const newChildId = /** @type {String} */ (
newChild.getAttribute("id")
);
if (ctx.persistentIds.has(newChildId)) {
// move it and all its children here and morph
const movedChild = moveBeforeById(
oldParent,
newChildId,
insertionPoint,
ctx,
);
morphNode(movedChild, newChild, ctx);
insertionPoint = movedChild.nextSibling;
continue;
}
}
// last resort: insert the new node from scratch
const insertedNode = createNode(
oldParent,
newChild,
insertionPoint,
ctx,
);
// could be null if beforeNodeAdded prevented insertion
if (insertedNode) {
insertionPoint = insertedNode.nextSibling;
}
}
// remove any remaining old nodes that didn't match up with new content
while (insertionPoint && insertionPoint != endPoint) {
const tempNode = insertionPoint;
insertionPoint = insertionPoint.nextSibling;
removeNode(ctx, tempNode);
}
}
/**
* This performs the action of inserting a new node while handling situations where the node contains
* elements with persistent ids and possible state info we can still preserve by moving in and then morphing
*
* @param {Element} oldParent
* @param {Node} newChild
* @param {Node|null} insertionPoint
* @param {MorphContext} ctx
* @returns {Node|null}
*/
function createNode(oldParent, newChild, insertionPoint, ctx) {
if (ctx.callbacks.beforeNodeAdded(newChild) === false) return null;
if (ctx.idMap.has(newChild)) {
// node has children with ids with possible state so create a dummy elt of same type and apply full morph algorithm
const newEmptyChild = document.createElement(
/** @type {Element} */ (newChild).tagName,
);
oldParent.insertBefore(newEmptyChild, insertionPoint);
morphNode(newEmptyChild, newChild, ctx);
ctx.callbacks.afterNodeAdded(newEmptyChild);
return newEmptyChild;
} else {
// optimisation: no id state to preserve so we can just insert a clone of the newChild and its descendants
const newClonedChild = document.importNode(newChild, true); // importNode to not mutate newParent
oldParent.insertBefore(newClonedChild, insertionPoint);
ctx.callbacks.afterNodeAdded(newClonedChild);
return newClonedChild;
}
}
//=============================================================================
// Matching Functions
//=============================================================================
const findBestMatch = (function () {
/**
* Scans forward from the startPoint to the endPoint looking for a match
* for the node. It looks for an id set match first, then a soft match.
* We abort softmatching if we find two future soft matches, to reduce churn.
* @param {Node} node
* @param {MorphContext} ctx
* @param {Node | null} startPoint
* @param {Node | null} endPoint
* @returns {Node | null}
*/
function findBestMatch(ctx, node, startPoint, endPoint) {
let softMatch = null;
let nextSibling = node.nextSibling;
let siblingSoftMatchCount = 0;
let cursor = startPoint;
while (cursor && cursor != endPoint) {
// soft matching is a prerequisite for id set matching
if (isSoftMatch(cursor, node)) {
if (isIdSetMatch(ctx, cursor, node)) {
return cursor; // found an id set match, we're done!
}
// we haven't yet saved a soft match fallback
if (softMatch === null) {
// the current soft match will hard match something else in the future, leave it
if (!ctx.idMap.has(cursor)) {
// save this as the fallback if we get through the loop without finding a hard match
softMatch = cursor;
}
}
}
if (
softMatch === null &&
nextSibling &&
isSoftMatch(cursor, nextSibling)
) {
// The next new node has a soft match with this node, so
// increment the count of future soft matches
siblingSoftMatchCount++;
nextSibling = nextSibling.nextSibling;
// If there are two future soft matches, block soft matching for this node to allow
// future siblings to soft match. This is to reduce churn in the DOM when an element
// is prepended.
if (siblingSoftMatchCount >= 2) {
softMatch = undefined;
}
}
// if the current node contains active element, stop looking for better future matches,
// because if one is found, this node will be moved to the pantry, reparenting it and thus losing focus
// @ts-ignore pretend cursor is Element rather than Node, we're just testing for array inclusion
if (ctx.activeElementAndParents.includes(cursor)) break;
cursor = cursor.nextSibling;
}
return softMatch || null;
}
/**
*
* @param {MorphContext} ctx
* @param {Node} oldNode
* @param {Node} newNode
* @returns {boolean}
*/
function isIdSetMatch(ctx, oldNode, newNode) {
let oldSet = ctx.idMap.get(oldNode);
let newSet = ctx.idMap.get(newNode);
if (!newSet || !oldSet) return false;
for (const id of oldSet) {
// a potential match is an id in the new and old nodes that
// has not already been merged into the DOM
// But the newNode content we call this on has not been
// merged yet and we don't allow duplicate IDs so it is simple
if (newSet.has(id)) {
return true;
}
}
return false;
}
/**
*
* @param {Node} oldNode
* @param {Node} newNode
* @returns {boolean}
*/
function isSoftMatch(oldNode, newNode) {
// ok to cast: if one is not element, `id` and `tagName` will be undefined and we'll just compare that.
const oldElt = /** @type {Element} */ (oldNode);
const newElt = /** @type {Element} */ (newNode);
return (
oldElt.nodeType === newElt.nodeType &&
oldElt.tagName === newElt.tagName &&
// If oldElt has an `id` with possible state and it doesn't match newElt.id then avoid morphing.
// We'll still match an anonymous node with an IDed newElt, though, because if it got this far,
// its not persistent, and new nodes can't have any hidden state.
// We can't use .id because of form input shadowing, and we can't count on .getAttribute's presence because it could be a document-fragment
(!oldElt.getAttribute?.("id") ||
oldElt.getAttribute?.("id") === newElt.getAttribute?.("id"))
);
}
return findBestMatch;
})();
//=============================================================================
// DOM Manipulation Functions
//=============================================================================
/**
* Gets rid of an unwanted DOM node; strategy depends on nature of its reuse:
* - Persistent nodes will be moved to the pantry for later reuse
* - Other nodes will have their hooks called, and then are removed
* @param {MorphContext} ctx
* @param {Node} node
*/
function removeNode(ctx, node) {
// are we going to id set match this later?
if (ctx.idMap.has(node)) {
// skip callbacks and move to pantry
moveBefore(ctx.pantry, node, null);
} else {
// remove for realsies
if (ctx.callbacks.beforeNodeRemoved(node) === false) return;
node.parentNode?.removeChild(node);
ctx.callbacks.afterNodeRemoved(node);
}
}
/**
* Remove nodes between the start and end nodes
* @param {MorphContext} ctx
* @param {Node} startInclusive
* @param {Node} endExclusive
* @returns {Node|null}
*/
function removeNodesBetween(ctx, startInclusive, endExclusive) {
/** @type {Node | null} */
let cursor = startInclusive;
// remove nodes until the endExclusive node
while (cursor && cursor !== endExclusive) {
let tempNode = /** @type {Node} */ (cursor);
cursor = cursor.nextSibling;
removeNode(ctx, tempNode);
}
return cursor;
}
/**
* Search for an element by id within the document and pantry, and move it using moveBefore.
*
* @param {Element} parentNode - The parent node to which the element will be moved.
* @param {string} id - The ID of the element to be moved.
* @param {Node | null} after - The reference node to insert the element before.
* If `null`, the element is appended as the last child.
* @param {MorphContext} ctx
* @returns {Element} The found element
*/
function moveBeforeById(parentNode, id, after, ctx) {
const target =
/** @type {Element} - will always be found */
(
// ctx.target.id unsafe because of form input shadowing
// ctx.target could be a document fragment which doesn't have `getAttribute`
(ctx.target.getAttribute?.("id") === id && ctx.target) ||
ctx.target.querySelector(`[id="${id}"]`) ||
ctx.pantry.querySelector(`[id="${id}"]`)
);
removeElementFromAncestorsIdMaps(target, ctx);
moveBefore(parentNode, target, after);
return target;
}
/**
* Removes an element from its ancestors' id maps. This is needed when an element is moved from the
* "future" via `moveBeforeId`. Otherwise, its erstwhile ancestors could be mistakenly moved to the
* pantry rather than being deleted, preventing their removal hooks from being called.
*
* @param {Element} element - element to remove from its ancestors' id maps
* @param {MorphContext} ctx
*/
function removeElementFromAncestorsIdMaps(element, ctx) {
// we know id is non-null String, because this function is only called on elements with ids
const id = /** @type {String} */ (element.getAttribute("id"));
/** @ts-ignore - safe to loop in this way **/
while ((element = element.parentNode)) {
let idSet = ctx.idMap.get(element);
if (idSet) {
idSet.delete(id);
if (!idSet.size) {
ctx.idMap.delete(element);
}
}
}
}
/**
* Moves an element before another element within the same parent.
* Uses the proposed `moveBefore` API if available (and working), otherwise falls back to `insertBefore`.
* This is essentialy a forward-compat wrapper.
*
* @param {Element} parentNode - The parent node containing the after element.
* @param {Node} element - The element to be moved.
* @param {Node | null} after - The reference node to insert `element` before.
* If `null`, `element` is appended as the last child.
*/
function moveBefore(parentNode, element, after) {
// @ts-ignore - use proposed moveBefore feature
if (parentNode.moveBefore) {
try {
// @ts-ignore - use proposed moveBefore feature
parentNode.moveBefore(element, after);
} catch (e) {
// fall back to insertBefore as some browsers may fail on moveBefore when trying to move Dom disconnected nodes to pantry
parentNode.insertBefore(element, after);
}
} else {
parentNode.insertBefore(element, after);
}
}
return morphChildren;
})();
//=============================================================================
// Single Node Morphing Code
//=============================================================================
const morphNode = (function () {
/**
* @param {Node} oldNode root node to merge content into
* @param {Node} newContent new content to merge
* @param {MorphContext} ctx the merge context
* @returns {Node | null} the element that ended up in the DOM
*/
function morphNode(oldNode, newContent, ctx) {
if (ctx.ignoreActive && oldNode === document.activeElement) {
// don't morph focused element
return null;
}
if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) {
return oldNode;
}
if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) ; else if (
oldNode instanceof HTMLHeadElement &&
ctx.head.style !== "morph"
) {
// ok to cast: if newContent wasn't also a <head>, it would've got caught in the `!isSoftMatch` branch above
handleHeadElement(
oldNode,
/** @type {HTMLHeadElement} */ (newContent),
ctx,
);
} else {
morphAttributes(oldNode, newContent, ctx);
if (!ignoreValueOfActiveElement(oldNode, ctx)) {
// @ts-ignore newContent can be a node here because .firstChild will be null
morphChildren(ctx, oldNode, newContent);
}
}
ctx.callbacks.afterNodeMorphed(oldNode, newContent);
return oldNode;
}
/**
* syncs the oldNode to the newNode, copying over all attributes and
* inner element state from the newNode to the oldNode
*
* @param {Node} oldNode the node to copy attributes & state to
* @param {Node} newNode the node to copy attributes & state from
* @param {MorphContext} ctx the merge context
*/
function morphAttributes(oldNode, newNode, ctx) {
let type = newNode.nodeType;
// if is an element type, sync the attributes from the
// new node into the new node
if (type === 1 /* element type */) {
const oldElt = /** @type {Element} */ (oldNode);
const newElt = /** @type {Element} */ (newNode);
const oldAttributes = oldElt.attributes;
const newAttributes = newElt.attributes;
for (const newAttribute of newAttributes) {
if (ignoreAttribute(newAttribute.name, oldElt, "update", ctx)) {
continue;
}
if (oldElt.getAttribute(newAttribute.name) !== newAttribute.value) {
oldElt.setAttribute(newAttribute.name, newAttribute.value);
}
}
// iterate backwards to avoid skipping over items when a delete occurs
for (let i = oldAttributes.length - 1; 0 <= i; i--) {
const oldAttribute = oldAttributes[i];
// toAttributes is a live NamedNodeMap, so iteration+mutation is unsafe
// e.g. custom element attribute callbacks can remove other attributes
if (!oldAttribute) continue;
if (!newElt.hasAttribute(oldAttribute.name)) {
if (ignoreAttribute(oldAttribute.name, oldElt, "remove", ctx)) {
continue;
}
oldElt.removeAttribute(oldAttribute.name);
}
}
if (!ignoreValueOfActiveElement(oldElt, ctx)) {
syncInputValue(oldElt, newElt, ctx);
}
}
// sync text nodes
if (type === 8 /* comment */ || type === 3 /* text */) {
if (oldNode.nodeValue !== newNode.nodeValue) {
oldNode.nodeValue = newNode.nodeValue;
}
}
}
/**
* NB: many bothans died to bring us information:
*
* https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js
* https://github.com/choojs/nanomorph/blob/master/lib/morph.jsL113
*
* @param {Element} oldElement the element to sync the input value to
* @param {Element} newElement the element to sync the input value from
* @param {MorphContext} ctx the merge context
*/
function syncInputValue(oldElement, newElement, ctx) {
if (
oldElement instanceof HTMLInputElement &&
newElement instanceof HTMLInputElement &&
newElement.type !== "file"
) {
let newValue = newElement.value;
let oldValue = oldElement.value;
// sync boolean attributes
syncBooleanAttribute(oldElement, newElement, "checked", ctx);
syncBooleanAttribute(oldElement, newElement, "disabled", ctx);
if (!newElement.hasAttribute("value")) {
if (!ignoreAttribute("value", oldElement, "remove", ctx)) {
oldElement.value = "";
oldElement.removeAttribute("value");
}
} else if (oldValue !== newValue) {
if (!ignoreAttribute("value", oldElement, "update", ctx)) {
oldElement.setAttribute("value", newValue);
oldElement.value = newValue;
}
}
// TODO: QUESTION(1cg): this used to only check `newElement` unlike the other branches -- why?
// did I break something?
} else if (
oldElement instanceof HTMLOptionElement &&
newElement instanceof HTMLOptionElement
) {
syncBooleanAttribute(oldElement, newElement, "selected", ctx);
} else if (
oldElement instanceof HTMLTextAreaElement &&
newElement instanceof HTMLTextAreaElement
) {
let newValue = newElement.value;
let oldValue = oldElement.value;
if (ignoreAttribute("value", oldElement, "update", ctx)) {
return;
}
if (newValue !== oldValue) {
oldElement.value = newValue;
}
if (
oldElement.firstChild &&
oldElement.firstChild.nodeValue !== newValue
) {
oldElement.firstChild.nodeValue = newValue;
}
}
}
/**
* @param {Element} oldElement element to write the value to
* @param {Element} newElement element to read the value from
* @param {string} attributeName the attribute name
* @param {MorphContext} ctx the merge context
*/
function syncBooleanAttribute(oldElement, newElement, attributeName, ctx) {
// @ts-ignore this function is only used on boolean attrs that are reflected as dom properties
const newLiveValue = newElement[attributeName],
// @ts-ignore ditto
oldLiveValue = oldElement[attributeName];
if (newLiveValue !== oldLiveValue) {
const ignoreUpdate = ignoreAttribute(
attributeName,
oldElement,
"update",
ctx,
);
if (!ignoreUpdate) {
// update attribute's associated DOM property
// @ts-ignore this function is only used on boolean attrs that are reflected as dom properties
oldElement[attributeName] = newElement[attributeName];
}
if (newLiveValue) {
if (!ignoreUpdate) {
// https://developer.mozilla.org/en-US/docs/Glossary/Boolean/HTML
// this is the correct way to set a boolean attribute to "true"
oldElement.setAttribute(attributeName, "");
}
} else {
if (!ignoreAttribute(attributeName, oldElement, "remove", ctx)) {
oldElement.removeAttribute(attributeName);
}
}
}
}
/**
* @param {string} attr the attribute to be mutated
* @param {Element} element the element that is going to be updated
* @param {"update" | "remove"} updateType
* @param {MorphContext} ctx the merge context
* @returns {boolean} true if the attribute should be ignored, false otherwise
*/
function ignoreAttribute(attr, element, updateType, ctx) {
if (
attr === "value" &&
ctx.ignoreActiveValue &&
element === document.activeElement
) {
return true;
}
return (
ctx.callbacks.beforeAttributeUpdated(attr, element, updateType) ===
false
);
}
/**
* @param {Node} possibleActiveElement
* @param {MorphContext} ctx
* @returns {boolean}
*/
function ignoreValueOfActiveElement(possibleActiveElement, ctx) {
return (
!!ctx.ignoreActiveValue &&
possibleActiveElement === document.activeElement &&
possibleActiveElement !== document.body
);
}
return morphNode;
})();
//=============================================================================
// Head Management Functions
//=============================================================================
/**
* @param {MorphContext} ctx
* @param {Element} oldNode
* @param {Element} newNode
* @param {function} callback
* @returns {Node[] | Promise<Node[]>}
*/
function withHeadBlocking(ctx, oldNode, newNode, callback) {
if (ctx.head.block) {
const oldHead = oldNode.querySelector("head");
const newHead = newNode.querySelector("head");
if (oldHead && newHead) {
const promises = handleHeadElement(oldHead, newHead, ctx);
// when head promises resolve, proceed ignoring the head tag
return Promise.all(promises).then(() => {
const newCtx = Object.assign(ctx, {
head: {
block: false,
ignore: true,
},
});
return callback(newCtx);
});
}
}
// just proceed if we not head blocking
return callback(ctx);
}
/**
* The HEAD tag can be handled specially, either w/ a 'merge' or 'append' style
*
* @param {Element} oldHead
* @param {Element} newHead
* @param {MorphContext} ctx
* @returns {Promise<void>[]}
*/
function handleHeadElement(oldHead, newHead, ctx) {
let added = [];
let removed = [];
let preserved = [];
let nodesToAppend = [];
// put all new head elements into a Map, by their outerHTML
let srcToNewHeadNodes = new Map();
for (const newHeadChild of newHead.children) {
srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild);
}
// for each elt in the current head
for (const currentHeadElt of oldHead.children) {
// If the current head element is in the map
let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML);
let isReAppended = ctx.head.shouldReAppend(currentHeadElt);
let isPreserved = ctx.head.shouldPreserve(currentHeadElt);
if (inNewContent || isPreserved) {
if (isReAppended) {
// remove the current version and let the new version replace it and re-execute
removed.push(currentHeadElt);
} else {
// this element already exists and should not be re-appended, so remove it from
// the new content map, preserving it in the DOM
srcToNewHeadNodes.delete(currentHeadElt.outerHTML);
preserved.push(currentHeadElt);
}
} else {
if (ctx.head.style === "append") {
// we are appending and this existing element is not new content
// so if and only if it is marked for re-append do we do anything
if (isReAppended) {
removed.push(currentHeadElt);
nodesToAppend.push(currentHeadElt);
}
} else {
// if this is a merge, we remove this content since it is not in the new head
if (ctx.head.shouldRemove(currentHeadElt) !== false) {
removed.push(currentHeadElt);
}
}
}
}
// Push the remaining new head elements in the Map into the
// nodes to append to the head tag
nodesToAppend.push(...srcToNewHeadNodes.values());
let promises = [];
for (const newNode of nodesToAppend) {
// TODO: This could theoretically be null, based on type
let newElt = /** @type {ChildNode} */ (
document.createRange().createContextualFragment(newNode.outerHTML)
.firstChild
);
if (ctx.callbacks.beforeNodeAdded(newElt) !== false) {
if (
("href" in newElt && newElt.href) ||
("src" in newElt && newElt.src)
) {
/** @type {(result?: any) => void} */ let resolve;
let promise = new Promise(function (_resolve) {
resolve = _resolve;
});
newElt.addEventListener("load", function () {
resolve();
});
promises.push(promise);
}
oldHead.appendChild(newElt);
ctx.callbacks.afterNodeAdded(newElt);
added.push(newElt);
}
}
// remove all removed elements, after we have appended the new elements to avoid
// additional network requests for things like style sheets
for (const removedElement of removed) {
if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) {
oldHead.removeChild(removedElement);
ctx.callbacks.afterNodeRemoved(removedElement);
}
}
ctx.head.afterHeadMorphed(oldHead, {
added: added,
kept: preserved,
removed: removed,
});
return promises;
}
//=============================================================================
// Create Morph Context Functions
//=============================================================================
const createMorphContext = (function () {
/**
*
* @param {Element} oldNode
* @param {Element} newContent
* @param {Config} config
* @returns {MorphContext}
*/
function createMorphContext(oldNode, newContent, config) {
const { persistentIds, idMap } = createIdMaps(oldNode, newContent);
const mergedConfig = mergeDefaults(config);
const morphStyle = mergedConfig.morphStyle || "outerHTML";
if (!["innerHTML", "outerHTML"].includes(morphStyle)) {
throw `Do not understand how to morph style ${morphStyle}`;
}
return {
target: oldNode,
newContent: newContent,
config: mergedConfig,
morphStyle: morphStyle,
ignoreActive: mergedConfig.ignoreActive,
ignoreActiveValue: mergedConfig.ignoreActiveValue,
restoreFocus: mergedConfig.restoreFocus,
idMap: idMap,
persistentIds: persistentIds,
pantry: createPantry(),
activeElementAndParents: createActiveElementAndParents(oldNode),
callbacks: mergedConfig.callbacks,
head: mergedConfig.head,
};
}
/**
* Deep merges the config object and the Idiomorph.defaults object to
* produce a final configuration object
* @param {Config} config
* @returns {ConfigInternal}
*/
function mergeDefaults(config) {
let finalConfig = Object.assign({}, defaults);
// copy top level stuff into final config
Object.assign(finalConfig, config);
// copy callbacks into final config (do this to deep merge the callbacks)
finalConfig.callbacks = Object.assign(
{},
defaults.callbacks,
config.callbacks,
);
// copy head config into final config (do this to deep merge the head)
finalConfig.head = Object.assign({}, defaults.head, config.head);
return finalConfig;
}
/**
* @returns {HTMLDivElement}
*/
function createPantry() {
const pantry = document.createElement("div");
pantry.hidden = true;
document.body.insertAdjacentElement("afterend", pantry);
return pantry;
}
/**
* @param {Element} oldNode
* @returns {Element[]}
*/
function createActiveElementAndParents(oldNode) {
/** @type {Element[]} */
let activeElementAndParents = [];
let elt = document.activeElement;
if (elt?.tagName !== "BODY" && oldNode.contains(elt)) {
while (elt) {
activeElementAndParents.push(elt);
if (elt === oldNode) break;
elt = elt.parentElement;
}
}
return activeElementAndParents;
}
/**
* Returns all elements with an ID contained within the root element and its descendants
*
* @param {Element} root
* @returns {Element[]}
*/
function findIdElements(root) {
let elements = Array.from(root.querySelectorAll("[id]"));
// root could be a document fragment which doesn't have `getAttribute`
if (root.getAttribute?.("id")) {
elements.push(root);
}
return elements;
}
/**
* A bottom-up algorithm that populates a map of Element -> IdSet.
* The idSet for a given element is the set of all IDs contained within its subtree.
* As an optimzation, we filter these IDs through the given list of persistent IDs,
* because we don't need to bother considering IDed elements that won't be in the new content.
*
* @param {Map<Node, Set<string>>} idMap
* @param {Set<string>} persistentIds
* @param {Element} root
* @param {Element[]} elements
*/
function populateIdMapWithTree(idMap, persistentIds, root, elements) {
for (const elt of elements) {
// we can pretend id is non-null String, because the .has line will reject it immediately if not
const id = /** @type {String} */ (elt.getAttribute("id"));
if (persistentIds.has(id)) {
/** @type {Element|null} */
let current = elt;
// walk up the parent hierarchy of that element, adding the id
// of element to the parent's id set
while (current) {
let idSet = idMap.get(current);
// if the id set doesn't exist, create it and insert it in the map
if (idSet == null) {
idSet = new Set();
idMap.set(current, idSet);
}
idSet.add(id);
if (current === root) break;
current = current.parentElement;
}
}
}
}
/**
* This function computes a map of nodes to all ids contained within that node (inclusive of the
* node). This map can be used to ask if two nodes have intersecting sets of ids, which allows
* for a looser definition of "matching" than tradition id matching, and allows child nodes
* to contribute to a parent nodes matching.
*
* @param {Element} oldContent the old content that will be morphed
* @param {Element} newContent the new content to morph to
* @returns {IdSets}
*/
function createIdMaps(oldContent, newContent) {
const oldIdElements = findIdElements(oldContent);
const newIdElements = findIdElements(newContent);
const persistentIds = createPersistentIds(oldIdElements, newIdElements);
/** @type {Map<Node, Set<string>>} */
let idMap = new Map();
populateIdMapWithTree(idMap, persistentIds, oldContent, oldIdElements);
/** @ts-ignore - if newContent is a duck-typed parent, pass its single child node as the root to halt upwards iteration */
const newRoot = newContent.__idiomorphRoot || newContent;
populateIdMapWithTree(idMap, persistentIds, newRoot, newIdElements);
return { persistentIds, idMap };
}
/**
* This function computes the set of ids that persist between the two contents excluding duplicates
*
* @param {Element[]} oldIdElements
* @param {Element[]} newIdElements
* @returns {Set<string>}
*/
function createPersistentIds(oldIdElements, newIdElements) {
let duplicateIds = new Set();
/** @type {Map<string, string>} */
let oldIdTagNameMap = new Map();
for (const { id, tagName } of oldIdElements) {
if (oldIdTagNameMap.has(id)) {
duplicateIds.add(id);
} else {
oldIdTagNameMap.set(id, tagName);
}
}
let persistentIds = new Set();
for (const { id, tagName } of newIdElements) {
if (persistentIds.has(id)) {
duplicateIds.add(id);
} else if (oldIdTagNameMap.get(id) === tagName) {
persistentIds.add(id);
}
// skip if tag types mismatch because its not possible to morph one tag into another
}
for (const id of duplicateIds) {
persistentIds.delete(id);
}
return persistentIds;
}
return createMorphContext;
})();
//=============================================================================
// HTML Normalization Functions
//=============================================================================
const { normalizeElement, normalizeParent } = (function () {
/** @type {WeakSet<Node>} */
const generatedByIdiomorph = new WeakSet();
/**
*
* @param {Element | Document} content
* @returns {Element}
*/
function normalizeElement(content) {
if (content instanceof Document) {
return content.documentElement;
} else {
return content;
}
}
/**
*
* @param {null | string | Node | HTMLCollection | Node[] | Document & {generatedByIdiomorph:boolean}} newContent
* @returns {Element}
*/
function normalizeParent(newContent) {
if (newContent == null) {
return document.createElement("div"); // dummy parent element
} else if (typeof newContent === "string") {
return normalizeParent(parseContent(newContent));
} else if (
generatedByIdiomorph.has(/** @type {Element} */ (newContent))
) {
// the template tag created by idiomorph parsing can serve as a dummy parent
return /** @type {Element} */ (newContent);
} else if (newContent instanceof Node) {
if (newContent.parentNode) {
// we can't use the parent directly because newContent may have siblings
// that we don't want in the morph, and reparenting might be expensive (TODO is it?),
// so instead we create a fake parent node that only sees a slice of its children.
/** @type {Element} */
return /** @type {any} */ (new SlicedParentNode(newContent));
} else {
// a single node is added as a child to a dummy parent
const dummyParent = document.createElement("div");
dummyParent.append(newContent);
return dummyParent;
}
} else {
// all nodes in the array or HTMLElement collection are consolidated under
// a single dummy parent element
const dummyParent = document.createElement("div");
for (const elt of [...newContent]) {
dummyParent.append(elt);
}
return dummyParent;
}
}
/**
* A fake duck-typed parent element to wrap a single node, without actually reparenting it.
* This is useful because the node may have siblings that we don't want in the morph, and it may also be moved
* or replaced with one or more elements during the morph. This class effectively allows us a window into
* a slice of a node's children.
* "If it walks like a duck, and quacks like a duck, then it must be a duck!" -- James Whitcomb Riley (18491916)
*/
class SlicedParentNode {
/** @param {Node} node */
constructor(node) {
this.originalNode = node;
this.realParentNode = /** @type {Element} */ (node.parentNode);
this.previousSibling = node.previousSibling;
this.nextSibling = node.nextSibling;
}
/** @returns {Node[]} */
get childNodes() {
// return slice of realParent's current childNodes, based on previousSibling and nextSibling
const nodes = [];
let cursor = this.previousSibling
? this.previousSibling.nextSibling
: this.realParentNode.firstChild;
while (cursor && cursor != this.nextSibling) {
nodes.push(cursor);
cursor = cursor.nextSibling;
}
return nodes;
}
/**
* @param {string} selector
* @returns {Element[]}
*/
querySelectorAll(selector) {
return this.childNodes.reduce((results, node) => {
if (node instanceof Element) {
if (node.matches(selector)) results.push(node);
const nodeList = node.querySelectorAll(selector);
for (let i = 0; i < nodeList.length; i++) {
results.push(nodeList[i]);
}
}
return results;
}, /** @type {Element[]} */ ([]));
}
/**
* @param {Node} node
* @param {Node} referenceNode
* @returns {Node}
*/
insertBefore(node, referenceNode) {
return this.realParentNode.insertBefore(node, referenceNode);
}
/**
* @param {Node} node
* @param {Node} referenceNode
* @returns {Node}
*/
moveBefore(node, referenceNode) {
// @ts-ignore - use new moveBefore feature
return this.realParentNode.moveBefore(node, referenceNode);
}
/**
* for later use with populateIdMapWithTree to halt upwards iteration
* @returns {Node}
*/
get __idiomorphRoot() {
return this.originalNode;
}
}
/**
*
* @param {string} newContent
* @returns {Node | null | DocumentFragment}
*/
function parseContent(newContent) {
let parser = new DOMParser();
// remove svgs to avoid false-positive matches on head, etc.
let contentWithSvgsRemoved = newContent.replace(
/<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim,
"",
);
// if the newContent contains a html, head or body tag, we can simply parse it w/o wrapping
if (
contentWithSvgsRemoved.match(/<\/html>/) ||
contentWithSvgsRemoved.match(/<\/head>/) ||
contentWithSvgsRemoved.match(/<\/body>/)
) {
let content = parser.parseFromString(newContent, "text/html");
// if it is a full HTML document, return the document itself as the parent container
if (contentWithSvgsRemoved.match(/<\/html>/)) {
generatedByIdiomorph.add(content);
return content;
} else {
// otherwise return the html element as the parent container
let htmlElement = content.firstChild;
if (htmlElement) {
generatedByIdiomorph.add(htmlElement);
}
return htmlElement;
}
} else {
// if it is partial HTML, wrap it in a template tag to provide a parent element and also to help
// deal with touchy tags like tr, tbody, etc.
let responseDoc = parser.parseFromString(
"<body><template>" + newContent + "</template></body>",
"text/html",
);
let content = /** @type {HTMLTemplateElement} */ (
responseDoc.body.querySelector("template")
).content;
generatedByIdiomorph.add(content);
return content;
}
}
return { normalizeElement, normalizeParent };
})();
//=============================================================================
// This is what ends up becoming the Idiomorph global object
//=============================================================================
return {
morph,
defaults,
};
})();
/**
* Morph the state of the currentElement based on the attributes and contents of
* the newElement. Morphing may dispatch turbo:before-morph-element,
* turbo:before-morph-attribute, and turbo:morph-element events.
*
* @param currentElement Element destination of morphing changes
* @param newElement Element source of morphing changes
*/
function morphElements(currentElement, newElement, { callbacks, ...options } = {}) {
Idiomorph.morph(currentElement, newElement, {
...options,
callbacks: new DefaultIdiomorphCallbacks(callbacks)
});
}
/**
* Morph the child elements of the currentElement based on the child elements of
* the newElement. Morphing children may dispatch turbo:before-morph-element,
* turbo:before-morph-attribute, and turbo:morph-element events.
*
* @param currentElement Element destination of morphing children changes
* @param newElement Element source of morphing children changes
*/
function morphChildren(currentElement, newElement, options = {}) {
morphElements(currentElement, newElement.childNodes, {
...options,
morphStyle: "innerHTML"
});
}
function shouldRefreshFrameWithMorphing(currentFrame, newFrame) {
return currentFrame instanceof FrameElement &&
currentFrame.shouldReloadWithMorph && (!newFrame || areFramesCompatibleForRefreshing(currentFrame, newFrame)) &&
!currentFrame.closest("[data-turbo-permanent]")
}
function areFramesCompatibleForRefreshing(currentFrame, newFrame) {
// newFrame cannot yet be an instance of FrameElement because custom
// elements don't get initialized until they're attached to the DOM, so
// test its Element#nodeName instead
return newFrame instanceof Element && newFrame.nodeName === "TURBO-FRAME" && currentFrame.id === newFrame.id &&
(!newFrame.getAttribute("src") || urlsAreEqual(currentFrame.src, newFrame.getAttribute("src")))
}
function closestFrameReloadableWithMorphing(node) {
return node.parentElement.closest("turbo-frame[src][refresh=morph]")
}
class DefaultIdiomorphCallbacks {
#beforeNodeMorphed
constructor({ beforeNodeMorphed } = {}) {
this.#beforeNodeMorphed = beforeNodeMorphed || (() => true);
}
beforeNodeAdded = (node) => {
return !(node.id && node.hasAttribute("data-turbo-permanent") && document.getElementById(node.id))
}
beforeNodeMorphed = (currentElement, newElement) => {
if (currentElement instanceof Element) {
if (!currentElement.hasAttribute("data-turbo-permanent") && this.#beforeNodeMorphed(currentElement, newElement)) {
const event = dispatch("turbo:before-morph-element", {
cancelable: true,
target: currentElement,
detail: { currentElement, newElement }
});
return !event.defaultPrevented
} else {
return false
}
}
}
beforeAttributeUpdated = (attributeName, target, mutationType) => {
const event = dispatch("turbo:before-morph-attribute", {
cancelable: true,
target,
detail: { attributeName, mutationType }
});
return !event.defaultPrevented
}
beforeNodeRemoved = (node) => {
return this.beforeNodeMorphed(node)
}
afterNodeMorphed = (currentElement, newElement) => {
if (currentElement instanceof Element) {
dispatch("turbo:morph-element", {
target: currentElement,
detail: { currentElement, newElement }
});
}
}
}
class MorphingFrameRenderer extends FrameRenderer {
static renderElement(currentElement, newElement) {
dispatch("turbo:before-frame-morph", {
target: currentElement,
detail: { currentElement, newElement }
});
morphChildren(currentElement, newElement, {
callbacks: {
beforeNodeMorphed: (node, newNode) => {
if (
shouldRefreshFrameWithMorphing(node, newNode) &&
closestFrameReloadableWithMorphing(node) === currentElement
) {
node.reload();
return false
}
return true
}
}
});
}
async preservingPermanentElements(callback) {
return await callback()
}
}
class ProgressBar {
static animationDuration = 300 /*ms*/
static get defaultCSS() {
return unindent`
.turbo-progress-bar {
position: fixed;
display: block;
top: 0;
left: 0;
height: 3px;
background: #0076ff;
z-index: 2147483647;
transition:
width ${ProgressBar.animationDuration}ms ease-out,
opacity ${ProgressBar.animationDuration / 2}ms ${ProgressBar.animationDuration / 2}ms ease-in;
transform: translate3d(0, 0, 0);
}
`
}
hiding = false
value = 0
visible = false
constructor() {
this.stylesheetElement = this.createStylesheetElement();
this.progressElement = this.createProgressElement();
this.installStylesheetElement();
this.setValue(0);
}
show() {
if (!this.visible) {
this.visible = true;
this.installProgressElement();
this.startTrickling();
}
}
hide() {
if (this.visible && !this.hiding) {
this.hiding = true;
this.fadeProgressElement(() => {
this.uninstallProgressElement();
this.stopTrickling();
this.visible = false;
this.hiding = false;
});
}
}
setValue(value) {
this.value = value;
this.refresh();
}
// Private
installStylesheetElement() {
document.head.insertBefore(this.stylesheetElement, document.head.firstChild);
}
installProgressElement() {
this.progressElement.style.width = "0";
this.progressElement.style.opacity = "1";
document.documentElement.insertBefore(this.progressElement, document.body);
this.refresh();
}
fadeProgressElement(callback) {
this.progressElement.style.opacity = "0";
setTimeout(callback, ProgressBar.animationDuration * 1.5);
}
uninstallProgressElement() {
if (this.progressElement.parentNode) {
document.documentElement.removeChild(this.progressElement);
}
}
startTrickling() {
if (!this.trickleInterval) {
this.trickleInterval = window.setInterval(this.trickle, ProgressBar.animationDuration);
}
}
stopTrickling() {
window.clearInterval(this.trickleInterval);
delete this.trickleInterval;
}
trickle = () => {
this.setValue(this.value + Math.random() / 100);
}
refresh() {
requestAnimationFrame(() => {
this.progressElement.style.width = `${10 + this.value * 90}%`;
});
}
createStylesheetElement() {
const element = document.createElement("style");
element.type = "text/css";
element.textContent = ProgressBar.defaultCSS;
const cspNonce = getCspNonce();
if (cspNonce) {
element.nonce = cspNonce;
}
return element
}
createProgressElement() {
const element = document.createElement("div");
element.className = "turbo-progress-bar";
return element
}
}
class HeadSnapshot extends Snapshot {
detailsByOuterHTML = this.children
.filter((element) => !elementIsNoscript(element))
.map((element) => elementWithoutNonce(element))
.reduce((result, element) => {
const { outerHTML } = element;
const details =
outerHTML in result
? result[outerHTML]
: {
type: elementType(element),
tracked: elementIsTracked(element),
elements: []
};
return {
...result,
[outerHTML]: {
...details,
elements: [...details.elements, element]
}
}
}, {})
get trackedElementSignature() {
return Object.keys(this.detailsByOuterHTML)
.filter((outerHTML) => this.detailsByOuterHTML[outerHTML].tracked)
.join("")
}
getScriptElementsNotInSnapshot(snapshot) {
return this.getElementsMatchingTypeNotInSnapshot("script", snapshot)
}
getStylesheetElementsNotInSnapshot(snapshot) {
return this.getElementsMatchingTypeNotInSnapshot("stylesheet", snapshot)
}
getElementsMatchingTypeNotInSnapshot(matchedType, snapshot) {
return Object.keys(this.detailsByOuterHTML)
.filter((outerHTML) => !(outerHTML in snapshot.detailsByOuterHTML))
.map((outerHTML) => this.detailsByOuterHTML[outerHTML])
.filter(({ type }) => type == matchedType)
.map(({ elements: [element] }) => element)
}
get provisionalElements() {
return Object.keys(this.detailsByOuterHTML).reduce((result, outerHTML) => {
const { type, tracked, elements } = this.detailsByOuterHTML[outerHTML];
if (type == null && !tracked) {
return [...result, ...elements]
} else if (elements.length > 1) {
return [...result, ...elements.slice(1)]
} else {
return result
}
}, [])
}
getMetaValue(name) {
const element = this.findMetaElementByName(name);
return element ? element.getAttribute("content") : null
}
findMetaElementByName(name) {
return Object.keys(this.detailsByOuterHTML).reduce((result, outerHTML) => {
const {
elements: [element]
} = this.detailsByOuterHTML[outerHTML];
return elementIsMetaElementWithName(element, name) ? element : result
}, undefined | undefined)
}
}
function elementType(element) {
if (elementIsScript(element)) {
return "script"
} else if (elementIsStylesheet(element)) {
return "stylesheet"
}
}
function elementIsTracked(element) {
return element.getAttribute("data-turbo-track") == "reload"
}
function elementIsScript(element) {
const tagName = element.localName;
return tagName == "script"
}
function elementIsNoscript(element) {
const tagName = element.localName;
return tagName == "noscript"
}
function elementIsStylesheet(element) {
const tagName = element.localName;
return tagName == "style" || (tagName == "link" && element.getAttribute("rel") == "stylesheet")
}
function elementIsMetaElementWithName(element, name) {
const tagName = element.localName;
return tagName == "meta" && element.getAttribute("name") == name
}
function elementWithoutNonce(element) {
if (element.hasAttribute("nonce")) {
element.setAttribute("nonce", "");
}
return element
}
class PageSnapshot extends Snapshot {
static fromHTMLString(html = "") {
return this.fromDocument(parseHTMLDocument(html))
}
static fromElement(element) {
return this.fromDocument(element.ownerDocument)
}
static fromDocument({ documentElement, body, head }) {
return new this(documentElement, body, new HeadSnapshot(head))
}
constructor(documentElement, body, headSnapshot) {
super(body);
this.documentElement = documentElement;
this.headSnapshot = headSnapshot;
}
clone() {
const clonedElement = this.element.cloneNode(true);
const selectElements = this.element.querySelectorAll("select");
const clonedSelectElements = clonedElement.querySelectorAll("select");
for (const [index, source] of selectElements.entries()) {
const clone = clonedSelectElements[index];
for (const option of clone.selectedOptions) option.selected = false;
for (const option of source.selectedOptions) clone.options[option.index].selected = true;
}
for (const clonedPasswordInput of clonedElement.querySelectorAll('input[type="password"]')) {
clonedPasswordInput.value = "";
}
for (const clonedNoscriptElement of clonedElement.querySelectorAll("noscript")) {
clonedNoscriptElement.remove();
}
return new PageSnapshot(this.documentElement, clonedElement, this.headSnapshot)
}
get lang() {
return this.documentElement.getAttribute("lang")
}
get dir() {
return this.documentElement.getAttribute("dir")
}
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() {
const viewTransitionEnabled = this.getSetting("view-transition") === "true" || this.headSnapshot.getMetaValue("view-transition") === "same-origin";
return viewTransitionEnabled && !window.matchMedia("(prefers-reduced-motion: reduce)").matches
}
get refreshMethod() {
return this.getSetting("refresh-method")
}
get refreshScroll() {
return this.getSetting("refresh-scroll")
}
// 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,
refresh: {}
};
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,
refresh
} = {
...defaultOptions,
...options
};
this.action = action;
this.historyChanged = historyChanged;
this.referrer = referrer;
this.snapshot = snapshot;
this.snapshotHTML = snapshotHTML;
this.response = response;
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];
this.refresh = refresh;
}
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)
}
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.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;
}
}
// 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();
}
this.scrolled = true;
}
}
scrollToRestoredPosition() {
const { scrollPosition } = this.restorationData;
if (scrollPosition) {
this.view.scrollToPosition(scrollPosition);
return true
}
}
scrollToAnchor() {
const anchor = getAnchor(this.location);
if (anchor != null) {
this.view.scrollToAnchor(anchor);
return true
}
}
// Instrumentation
recordTimingMetric(metric) {
this.timingMetrics[metric] = new Date().getTime();
}
getTimingMetrics() {
return { ...this.timingMetrics }
}
// Private
hasPreloadedResponse() {
return typeof this.response == "object"
}
shouldIssueRequest() {
if (this.action == "restore") {
return !this.hasCachedSnapshot()
} else {
return this.willRender
}
}
cacheSnapshot() {
if (!this.snapshotCached) {
this.view.cacheSnapshot(this.snapshot).then((snapshot) => snapshot && this.visitCachedSnapshot(snapshot));
this.snapshotCached = true;
}
}
async render(callback) {
this.cancelRender();
await new Promise((resolve) => {
this.frame =
document.visibilityState === "hidden" ? setTimeout(() => resolve(), 0) : requestAnimationFrame(() => resolve());
});
await callback();
delete this.frame;
}
async renderPageSnapshot(snapshot, isPreview) {
await this.viewTransitioner.renderChange(this.view.shouldTransitionTo(snapshot), async () => {
await this.view.renderPage(snapshot, isPreview, this.willRender, this);
this.performScroll();
});
}
cancelRender() {
if (this.frame) {
cancelAnimationFrame(this.frame);
delete this.frame;
}
}
}
function isSuccessful(statusCode) {
return statusCode >= 200 && statusCode < 300
}
class BrowserAdapter {
progressBar = new ProgressBar()
constructor(session) {
this.session = session;
}
visitProposedToLocation(location, options) {
if (locationIsVisitable(location, this.navigator.rootLocation)) {
this.navigator.startVisit(location, options?.restorationIdentifier || uuid(), options);
} else {
window.location.href = location.toString();
}
}
visitStarted(visit) {
this.location = visit.location;
this.redirectedToLocation = null;
visit.loadCachedSnapshot();
visit.issueRequest();
}
visitRequestStarted(visit) {
this.progressBar.setValue(0);
if (visit.hasCachedSnapshot() || visit.action != "restore") {
this.showVisitProgressBarAfterDelay();
} else {
this.showProgressBar();
}
}
visitRequestCompleted(visit) {
visit.loadResponse();
if (visit.response.redirected) {
this.redirectedToLocation = visit.redirectedToLocation;
}
}
visitRequestFailedWithStatusCode(visit, statusCode) {
switch (statusCode) {
case SystemStatusCode.networkFailure:
case SystemStatusCode.timeoutFailure:
case SystemStatusCode.contentTypeMismatch:
return this.reload({
reason: "request_failed",
context: {
statusCode
}
})
default:
return visit.loadResponse()
}
}
visitRequestFinished(_visit) {}
visitCompleted(_visit) {
this.progressBar.setValue(1);
this.hideVisitProgressBar();
}
pageInvalidated(reason) {
this.reload(reason);
}
visitFailed(_visit) {
this.progressBar.setValue(1);
this.hideVisitProgressBar();
}
visitRendered(_visit) {}
// Link prefetching
linkPrefetchingIsEnabledForLocation(location) {
return true
}
// Form Submission Delegate
formSubmissionStarted(_formSubmission) {
this.progressBar.setValue(0);
this.showFormProgressBarAfterDelay();
}
formSubmissionFinished(_formSubmission) {
this.progressBar.setValue(1);
this.hideFormProgressBar();
}
// Private
showVisitProgressBarAfterDelay() {
this.visitProgressBarTimeout = window.setTimeout(this.showProgressBar, this.session.progressBarDelay);
}
hideVisitProgressBar() {
this.progressBar.hide();
if (this.visitProgressBarTimeout != null) {
window.clearTimeout(this.visitProgressBarTimeout);
delete this.visitProgressBarTimeout;
}
}
showFormProgressBarAfterDelay() {
if (this.formProgressBarTimeout == null) {
this.formProgressBarTimeout = window.setTimeout(this.showProgressBar, this.session.progressBarDelay);
}
}
hideFormProgressBar() {
this.progressBar.hide();
if (this.formProgressBarTimeout != null) {
window.clearTimeout(this.formProgressBarTimeout);
delete this.formProgressBarTimeout;
}
}
showProgressBar = () => {
this.progressBar.show();
}
reload(reason) {
dispatch("turbo:reload", { detail: reason });
window.location.href = (this.redirectedToLocation || this.location)?.toString() || window.location.href;
}
get navigator() {
return this.session.navigator
}
}
class CacheObserver {
selector = "[data-turbo-temporary]"
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)]
}
}
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
currentIndex = 0
constructor(delegate) {
this.delegate = delegate;
}
start() {
if (!this.started) {
addEventListener("popstate", this.onPopState, 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);
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) => {
const { turbo } = event.state || {};
this.location = new URL(window.location.href);
if (turbo) {
const { restorationIdentifier, restorationIndex } = turbo;
this.restorationIdentifier = restorationIdentifier;
const direction = restorationIndex > this.currentIndex ? "forward" : "back";
this.delegate.historyPoppedToLocationWithRestorationIdentifierAndDirection(this.location, restorationIdentifier, direction);
this.currentIndex = restorationIndex;
} else {
this.currentIndex++;
this.delegate.historyPoppedWithEmptyState(this.location);
}
}
}
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
);
fetchRequest.fetchOptions.priority = "low";
prefetchCache.putLater(location, 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);
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.refreshScroll !== "preserve") {
this.view.scrollToTop();
}
this.view.clearSnapshotCache();
}
}
formSubmissionErrored(formSubmission, error) {
console.error(error);
}
formSubmissionFinished(formSubmission) {
// Not all adapters implement formSubmissionFinished
if (typeof this.adapter.formSubmissionFinished === "function") {
this.adapter.formSubmissionFinished(formSubmission);
}
}
// Link prefetching
linkPrefetchingIsEnabledForLocation(location) {
// Not all adapters implement linkPrefetchingIsEnabledForLocation
if (typeof this.adapter.linkPrefetchingIsEnabledForLocation === "function") {
return this.adapter.linkPrefetchingIsEnabledForLocation(location)
}
return true
}
// Visit delegate
visitStarted(visit) {
this.delegate.visitStarted(visit);
}
visitCompleted(visit) {
this.delegate.visitCompleted(visit);
delete this.currentVisit;
}
// Same-page links are no longer handled with a Visit.
// This method is still needed for Turbo Native adapters.
locationWithActionIsSamePage(location, action) {
return false
}
// Visits
get location() {
return this.history.location
}
get restorationIdentifier() {
return this.history.restorationIdentifier
}
#getActionForFormSubmission(formSubmission, fetchResponse) {
const { submitter, formElement } = formSubmission;
return getVisitAction(submitter, formElement) || this.#getDefaultAction(fetchResponse)
}
#getDefaultAction(fetchResponse) {
const sameLocationRedirect = fetchResponse.redirected && fetchResponse.location.href === this.location?.href;
return sameLocationRedirect ? "replace" : "advance"
}
}
const PageStage = {
initial: 0,
loading: 1,
interactive: 2,
complete: 3
};
class PageObserver {
stage = PageStage.initial
started = false
constructor(delegate) {
this.delegate = delegate;
}
start() {
if (!this.started) {
if (this.stage == PageStage.initial) {
this.stage = PageStage.loading;
}
document.addEventListener("readystatechange", this.interpretReadyState, false);
addEventListener("pagehide", this.pageWillUnload, false);
this.started = true;
}
}
stop() {
if (this.started) {
document.removeEventListener("readystatechange", this.interpretReadyState, false);
removeEventListener("pagehide", this.pageWillUnload, false);
this.started = false;
}
}
interpretReadyState = () => {
const { readyState } = this;
if (readyState == "interactive") {
this.pageIsInteractive();
} else if (readyState == "complete") {
this.pageIsComplete();
}
}
pageIsInteractive() {
if (this.stage == PageStage.loading) {
this.stage = PageStage.interactive;
this.delegate.pageBecameInteractive();
}
}
pageIsComplete() {
this.pageIsInteractive();
if (this.stage == PageStage.interactive) {
this.stage = PageStage.complete;
this.delegate.pageLoaded();
}
}
pageWillUnload = () => {
this.delegate.pageWillUnload();
}
get readyState() {
return document.readyState
}
}
class ScrollObserver {
started = false
constructor(delegate) {
this.delegate = delegate;
}
start() {
if (!this.started) {
addEventListener("scroll", this.onScroll, false);
this.onScroll();
this.started = true;
}
}
stop() {
if (this.started) {
removeEventListener("scroll", this.onScroll, false);
this.started = false;
}
}
onScroll = () => {
this.updatePosition({ x: window.pageXOffset, y: window.pageYOffset });
}
// Private
updatePosition(position) {
this.delegate.scrollPositionChanged(position);
}
}
class StreamMessageRenderer {
render({ fragment }) {
Bardo.preservingPermanentElements(this, getPermanentElementMapForFragment(fragment), () => {
withAutofocusFromFragment(fragment, () => {
withPreservedFocus(() => {
document.documentElement.appendChild(fragment);
});
});
});
}
// Bardo delegate
enteringBardo(currentPermanentElement, newPermanentElement) {
newPermanentElement.replaceWith(currentPermanentElement.cloneNode(true));
}
leavingBardo() {}
}
function getPermanentElementMapForFragment(fragment) {
const permanentElementsInDocument = queryPermanentElementsAll(document.documentElement);
const permanentElementMap = {};
for (const permanentElementInDocument of permanentElementsInDocument) {
const { id } = permanentElementInDocument;
for (const streamElement of fragment.querySelectorAll("turbo-stream")) {
const elementInStream = getPermanentElementById(streamElement.templateElement.content, id);
if (elementInStream) {
permanentElementMap[id] = [permanentElementInDocument, elementInStream];
}
}
}
return permanentElementMap
}
async function withAutofocusFromFragment(fragment, callback) {
const generatedID = `turbo-stream-autofocus-${uuid()}`;
const turboStreams = fragment.querySelectorAll("turbo-stream");
const elementWithAutofocus = firstAutofocusableElementInStreams(turboStreams);
let willAutofocusId = null;
if (elementWithAutofocus) {
if (elementWithAutofocus.id) {
willAutofocusId = elementWithAutofocus.id;
} else {
willAutofocusId = generatedID;
}
elementWithAutofocus.id = willAutofocusId;
}
callback();
await nextRepaint();
const hasNoActiveElement = document.activeElement == null || document.activeElement == document.body;
if (hasNoActiveElement && willAutofocusId) {
const elementToAutofocus = document.getElementById(willAutofocusId);
if (elementIsFocusable(elementToAutofocus)) {
elementToAutofocus.focus();
}
if (elementToAutofocus && elementToAutofocus.id == generatedID) {
elementToAutofocus.removeAttribute("id");
}
}
}
async function withPreservedFocus(callback) {
const [activeElementBeforeRender, activeElementAfterRender] = await around(callback, () => document.activeElement);
const restoreFocusTo = activeElementBeforeRender && activeElementBeforeRender.id;
if (restoreFocusTo) {
const elementToFocus = document.getElementById(restoreFocusTo);
if (elementIsFocusable(elementToFocus) && elementToFocus != activeElementAfterRender) {
elementToFocus.focus();
}
}
}
function firstAutofocusableElementInStreams(nodeListOfStreamElements) {
for (const streamElement of nodeListOfStreamElements) {
const elementWithAutofocus = queryAutofocusableElement(streamElement.templateElement.content);
if (elementWithAutofocus) return elementWithAutofocus
}
return null
}
class StreamObserver {
sources = new Set()
#started = false
constructor(delegate) {
this.delegate = delegate;
}
start() {
if (!this.#started) {
this.#started = true;
addEventListener("turbo:before-fetch-response", this.inspectFetchResponse, false);
}
}
stop() {
if (this.#started) {
this.#started = false;
removeEventListener("turbo:before-fetch-response", this.inspectFetchResponse, false);
}
}
connectStreamSource(source) {
if (!this.streamSourceIsConnected(source)) {
this.sources.add(source);
source.addEventListener("message", this.receiveMessageEvent, false);
}
}
disconnectStreamSource(source) {
if (this.streamSourceIsConnected(source)) {
this.sources.delete(source);
source.removeEventListener("message", this.receiveMessageEvent, false);
}
}
streamSourceIsConnected(source) {
return this.sources.has(source)
}
inspectFetchResponse = (event) => {
const response = fetchResponseFromEvent(event);
if (response && fetchResponseIsStream(response)) {
event.preventDefault();
this.receiveMessageResponse(response);
}
}
receiveMessageEvent = (event) => {
if (this.#started && typeof event.data == "string") {
this.receiveMessageHTML(event.data);
}
}
async receiveMessageResponse(response) {
const html = await response.responseHTML;
if (html) {
this.receiveMessageHTML(html);
}
}
receiveMessageHTML(html) {
this.delegate.receivedMessageFromStream(StreamMessage.wrap(html));
}
}
function fetchResponseFromEvent(event) {
const fetchResponse = event.detail?.fetchResponse;
if (fetchResponse instanceof FetchResponse) {
return fetchResponse
}
}
function fetchResponseIsStream(response) {
const contentType = response.contentType ?? "";
return contentType.startsWith(StreamMessage.contentType)
}
class ErrorRenderer extends Renderer {
static renderElement(currentElement, newElement) {
const { documentElement, body } = document;
documentElement.replaceChild(newElement, body);
}
async render() {
this.replaceHeadAndBody();
this.activateScriptElements();
}
replaceHeadAndBody() {
const { documentElement, head } = document;
documentElement.replaceChild(this.newHead, head);
this.renderElement(this.currentElement, this.newElement);
}
activateScriptElements() {
for (const replaceableElement of this.scriptElements) {
const parentNode = replaceableElement.parentNode;
if (parentNode) {
const element = activateScriptElement(replaceableElement);
parentNode.replaceChild(element, replaceableElement);
}
}
}
get newHead() {
return this.newSnapshot.headSnapshot.element
}
get scriptElements() {
return document.documentElement.querySelectorAll("script")
}
}
class PageRenderer extends Renderer {
static renderElement(currentElement, newElement) {
if (document.body && newElement instanceof HTMLBodyElement) {
document.body.replaceWith(newElement);
} else {
document.documentElement.appendChild(newElement);
}
}
get shouldRender() {
return this.newSnapshot.isVisitable && this.trackedElementsAreIdentical
}
get reloadReason() {
if (!this.newSnapshot.isVisitable) {
return {
reason: "turbo_visit_control_is_reload"
}
}
if (!this.trackedElementsAreIdentical) {
return {
reason: "tracked_element_mismatch"
}
}
}
async prepareToRender() {
this.#setLanguage();
await this.mergeHead();
}
async render() {
if (this.willRender) {
await this.replaceBody();
}
}
finishRendering() {
super.finishRendering();
if (!this.isPreview) {
this.focusFirstAutofocusableElement();
}
}
get currentHeadSnapshot() {
return this.currentSnapshot.headSnapshot
}
get newHeadSnapshot() {
return this.newSnapshot.headSnapshot
}
get newElement() {
return this.newSnapshot.element
}
#setLanguage() {
const { documentElement } = this.currentSnapshot;
const { dir, lang } = this.newSnapshot;
if (lang) {
documentElement.setAttribute("lang", lang);
} else {
documentElement.removeAttribute("lang");
}
if (dir) {
documentElement.setAttribute("dir", dir);
} else {
documentElement.removeAttribute("dir");
}
}
async mergeHead() {
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.removeNoscriptElements();
this.activateNewBodyScriptElements();
}
removeNoscriptElements() {
for (const noscriptElement of this.newElement.querySelectorAll("noscript")) {
noscriptElement.remove();
}
}
activateNewBodyScriptElements() {
for (const inertScriptElement of this.newBodyScriptElements) {
const activatedScriptElement = activateScriptElement(inertScriptElement);
inertScriptElement.replaceWith(activatedScriptElement);
}
}
async assignNewBody() {
await this.renderElement(this.currentElement, this.newElement);
}
get unusedDynamicStylesheetElements() {
return this.oldHeadStylesheetElements.filter((element) => {
return element.getAttribute("data-turbo-track") === "dynamic"
})
}
get oldHeadStylesheetElements() {
return this.currentHeadSnapshot.getStylesheetElementsNotInSnapshot(this.newHeadSnapshot)
}
get newHeadStylesheetElements() {
return this.newHeadSnapshot.getStylesheetElementsNotInSnapshot(this.currentHeadSnapshot)
}
get newHeadScriptElements() {
return this.newHeadSnapshot.getScriptElementsNotInSnapshot(this.currentHeadSnapshot)
}
get currentHeadProvisionalElements() {
return this.currentHeadSnapshot.provisionalElements
}
get newHeadProvisionalElements() {
return this.newHeadSnapshot.provisionalElements
}
get newBodyScriptElements() {
return this.newElement.querySelectorAll("script")
}
}
class MorphingPageRenderer extends PageRenderer {
static renderElement(currentElement, newElement) {
morphElements(currentElement, newElement, {
callbacks: {
beforeNodeMorphed: (node, newNode) => {
if (
shouldRefreshFrameWithMorphing(node, newNode) &&
!closestFrameReloadableWithMorphing(node)
) {
node.reload();
return false
}
return true
}
}
});
dispatch("turbo:morph", { detail: { currentElement, newElement } });
}
async preservingPermanentElements(callback) {
return await callback()
}
get renderMethod() {
return "morph"
}
get shouldAutofocus() {
return false
}
}
class SnapshotCache extends LRUCache {
constructor(size) {
super(size, toCacheKey);
}
get snapshots() {
return this.entries
}
}
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) && (visit?.refresh?.method || this.snapshot.refreshMethod) === "morph";
const rendererClass = shouldMorphPage ? MorphingPageRenderer : PageRenderer;
const renderer = new rendererClass(this.snapshot, snapshot, isPreview, willRender);
if (!renderer.shouldRender) {
this.forceReloaded = true;
} else {
visit?.changeHistory();
}
return this.render(renderer)
}
renderError(snapshot, visit) {
visit?.changeHistory();
const renderer = new ErrorRenderer(this.snapshot, snapshot, false);
return this.render(renderer)
}
clearSnapshotCache() {
this.snapshotCache.clear();
}
async cacheSnapshot(snapshot = this.snapshot) {
if (snapshot.isCacheable) {
this.delegate.viewWillCacheSnapshot();
const { lastRenderedLocation: location } = this;
await nextEventLoopTick();
const cachedSnapshot = snapshot.clone();
this.snapshotCache.put(location, cachedSnapshot);
return cachedSnapshot
}
}
getCachedSnapshotForLocation(location) {
return this.snapshotCache.get(location)
}
isPageRefresh(visit) {
return !visit || (this.lastRenderedLocation.pathname === visit.location.pathname && visit.action === "replace")
}
shouldPreserveScrollPosition(visit) {
return this.isPageRefresh(visit) && (visit?.refresh?.scroll || this.snapshot.refreshScroll) === "preserve"
}
get snapshot() {
return PageSnapshot.fromElement(this.element)
}
}
class Preloader {
selector = "a[data-turbo-preload]"
constructor(delegate, snapshotCache) {
this.delegate = delegate;
this.snapshotCache = snapshotCache;
}
start() {
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", this.#preloadAll);
} else {
this.preloadOnLoadLinksForView(document.body);
}
}
stop() {
document.removeEventListener("DOMContentLoaded", this.#preloadAll);
}
preloadOnLoadLinksForView(element) {
for (const link of element.querySelectorAll(this.selector)) {
if (this.delegate.shouldPreloadLink(link)) {
this.preloadURL(link);
}
}
}
async preloadURL(link) {
const location = new URL(link.href);
if (this.snapshotCache.has(location)) {
return
}
const fetchRequest = new FetchRequest(this, FetchMethod.get, location, new URLSearchParams(), link);
await fetchRequest.perform();
}
// Fetch request delegate
prepareRequest(fetchRequest) {
fetchRequest.headers["X-Sec-Purpose"] = "prefetch";
}
async requestSucceededWithResponse(fetchRequest, fetchResponse) {
try {
const responseHTML = await fetchResponse.responseHTML;
const snapshot = PageSnapshot.fromHTMLString(responseHTML);
this.snapshotCache.put(fetchRequest.url, snapshot);
} catch (_) {
// If we cannot preload that is ok!
}
}
requestStarted(fetchRequest) {}
requestErrored(fetchRequest) {}
requestFinished(fetchRequest) {}
requestPreventedHandlingResponse(fetchRequest, fetchResponse) {}
requestFailedWithResponse(fetchRequest, fetchResponse) {}
#preloadAll = () => {
this.preloadOnLoadLinksForView(document.body);
}
}
class Cache {
constructor(session) {
this.session = session;
}
clear() {
this.session.clearCache();
}
resetCacheControl() {
this.#setCacheControl("");
}
exemptPageFromCache() {
this.#setCacheControl("no-cache");
}
exemptPageFromPreview() {
this.#setCacheControl("no-preview");
}
#setCacheControl(value) {
setMetaContent("turbo-cache-control", value);
}
}
class Session {
navigator = new Navigator(this)
history = new History(this)
view = new PageView(this, document.documentElement)
adapter = new BrowserAdapter(this)
pageObserver = new PageObserver(this)
cacheObserver = new CacheObserver()
linkPrefetchObserver = new LinkPrefetchObserver(this, document)
linkClickObserver = new LinkClickObserver(this, window)
formSubmitObserver = new FormSubmitObserver(this, document)
scrollObserver = new ScrollObserver(this)
streamObserver = new StreamObserver(this)
formLinkClickObserver = new FormLinkClickObserver(this, document.documentElement)
frameRedirector = new FrameRedirector(this, document.documentElement)
streamMessageRenderer = new StreamMessageRenderer()
cache = new Cache(this)
enabled = true
started = false
#pageRefreshDebouncePeriod = 150
constructor(recentRequests) {
this.recentRequests = recentRequests;
this.preloader = new Preloader(this, this.view.snapshotCache);
this.debouncedRefresh = this.refresh;
this.pageRefreshDebouncePeriod = this.pageRefreshDebouncePeriod;
}
start() {
if (!this.started) {
this.pageObserver.start();
this.cacheObserver.start();
this.linkPrefetchObserver.start();
this.formLinkClickObserver.start();
this.linkClickObserver.start();
this.formSubmitObserver.start();
this.scrollObserver.start();
this.streamObserver.start();
this.frameRedirector.start();
this.history.start();
this.preloader.start();
this.started = true;
this.enabled = true;
}
}
disable() {
this.enabled = false;
}
stop() {
if (this.started) {
this.pageObserver.stop();
this.cacheObserver.stop();
this.linkPrefetchObserver.stop();
this.formLinkClickObserver.stop();
this.linkClickObserver.stop();
this.formSubmitObserver.stop();
this.scrollObserver.stop();
this.streamObserver.stop();
this.frameRedirector.stop();
this.history.stop();
this.preloader.stop();
this.started = false;
}
}
registerAdapter(adapter) {
this.adapter = adapter;
}
visit(location, options = {}) {
const frameElement = options.frame ? document.getElementById(options.frame) : null;
if (frameElement instanceof FrameElement) {
const action = options.action || getVisitAction(frameElement);
frameElement.delegate.proposeVisitIfNavigatedWithAction(frameElement, action);
frameElement.src = location.toString();
} else {
this.navigator.proposeVisit(expandURL(location), options);
}
}
refresh(url, options = {}) {
options = typeof options === "string" ? { requestId: options } : options;
const { method, requestId, scroll } = options;
const isRecentRequest = requestId && this.recentRequests.has(requestId);
const isCurrentUrl = url === document.baseURI;
if (!isRecentRequest && !this.navigator.currentVisit && isCurrentUrl) {
this.visit(url, { action: "replace", shouldCacheSnapshot: false, refresh: { method, scroll } });
}
}
connectStreamSource(source) {
this.streamObserver.connectStreamSource(source);
}
disconnectStreamSource(source) {
this.streamObserver.disconnectStreamSource(source);
}
renderStreamMessage(message) {
this.streamMessageRenderer.render(StreamMessage.wrap(message));
}
clearCache() {
this.view.clearSnapshotCache();
}
setProgressBarDelay(delay) {
console.warn(
"Please replace `session.setProgressBarDelay(delay)` with `session.progressBarDelay = delay`. The function is deprecated and will be removed in a future version of Turbo.`"
);
this.progressBarDelay = delay;
}
set progressBarDelay(delay) {
config.drive.progressBarDelay = delay;
}
get progressBarDelay() {
return config.drive.progressBarDelay
}
set drive(value) {
config.drive.enabled = value;
}
get drive() {
return config.drive.enabled
}
set formMode(value) {
config.forms.mode = value;
}
get formMode() {
return config.forms.mode
}
get location() {
return this.history.location
}
get restorationIdentifier() {
return this.history.restorationIdentifier
}
get pageRefreshDebouncePeriod() {
return this.#pageRefreshDebouncePeriod
}
set pageRefreshDebouncePeriod(value) {
this.refresh = debounce(this.debouncedRefresh.bind(this), value);
this.#pageRefreshDebouncePeriod = value;
}
// Preloader delegate
shouldPreloadLink(element) {
const isUnsafe = element.hasAttribute("data-turbo-method");
const isStream = element.hasAttribute("data-turbo-stream");
const frameTarget = element.getAttribute("data-turbo-frame");
const frame = frameTarget == "_top" ?
null :
document.getElementById(frameTarget) || findClosestRecursively(element, "turbo-frame:not([disabled])");
if (isUnsafe || isStream || frame instanceof FrameElement) {
return false
} else {
const location = new URL(element.href);
return this.elementIsNavigatable(element) && locationIsVisitable(location, this.snapshot.rootLocation)
}
}
// History delegate
historyPoppedToLocationWithRestorationIdentifierAndDirection(location, restorationIdentifier, direction) {
if (this.enabled) {
this.navigator.startVisit(location, restorationIdentifier, {
action: "restore",
historyChanged: true,
direction
});
} else {
this.adapter.pageInvalidated({
reason: "turbo_disabled"
});
}
}
historyPoppedWithEmptyState(location) {
this.history.replace(location);
this.view.lastRenderedLocation = location;
this.view.cacheSnapshot();
}
// Scroll observer delegate
scrollPositionChanged(position) {
this.history.updateRestorationData({ scrollPosition: position });
}
// Form click observer delegate
willSubmitFormLinkToLocation(link, location) {
return this.elementIsNavigatable(link) && locationIsVisitable(location, this.snapshot.rootLocation)
}
submittedFormLinkToLocation() {}
// Link hover observer delegate
canPrefetchRequestToLocation(link, location) {
return (
this.elementIsNavigatable(link) &&
locationIsVisitable(location, this.snapshot.rootLocation) &&
this.navigator.linkPrefetchingIsEnabledForLocation(location)
)
}
// Link click observer delegate
willFollowLinkToLocation(link, location, event) {
return (
this.elementIsNavigatable(link) &&
locationIsVisitable(location, this.snapshot.rootLocation) &&
this.applicationAllowsFollowingLinkToLocation(link, location, event)
)
}
followedLinkToLocation(link, location) {
const action = this.getActionForLink(link);
const acceptsStreamResponse = link.hasAttribute("data-turbo-stream");
this.visit(location.href, { action, acceptsStreamResponse });
}
// Navigator delegate
allowsVisitingLocationWithAction(location, action) {
return this.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);
this.notifyApplicationAfterVisitingLocation(visit.location, visit.action);
}
visitCompleted(visit) {
this.view.unmarkVisitDirection();
clearBusyState(document.documentElement);
this.notifyApplicationAfterPageLoad(visit.getTimingMetrics());
}
// 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() {
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 }
})
}
notifyApplicationAfterFrameLoad(frame) {
return dispatch("turbo:frame-load", { target: frame })
}
notifyApplicationAfterFrameRender(fetchResponse, frame) {
return dispatch("turbo:frame-render", {
detail: { fetchResponse },
target: frame,
cancelable: true
})
}
// Helpers
submissionIsNavigatable(form, submitter) {
if (config.forms.mode == "off") {
return false
} else {
const submitterIsNavigatable = submitter ? this.elementIsNavigatable(submitter) : true;
if (config.forms.mode == "optin") {
return submitterIsNavigatable && form.closest('[data-turbo="true"]') != null
} else {
return submitterIsNavigatable && this.elementIsNavigatable(form)
}
}
}
elementIsNavigatable(element) {
const container = findClosestRecursively(element, "[data-turbo]");
const withinFrame = findClosestRecursively(element, "turbo-frame");
// Check if Drive is enabled on the session or we're within a Frame.
if (config.drive.enabled || withinFrame) {
// Element is navigatable by default, unless `data-turbo="false"`.
if (container) {
return container.getAttribute("data-turbo") != "false"
} else {
return true
}
} else {
// Element isn't navigatable by default, unless `data-turbo="true"`.
if (container) {
return container.getAttribute("data-turbo") == "true"
} else {
return false
}
}
}
// Private
getActionForLink(link) {
return getVisitAction(link) || "advance"
}
get snapshot() {
return this.view.snapshot
}
}
// Older versions of the Turbo Native adapters referenced the
// `Location#absoluteURL` property in their implementations of
// the `Adapter#visitProposedToLocation()` and `#visitStarted()`
// methods. The Location class has since been removed in favor
// of the DOM URL API, and accordingly all Adapter methods now
// receive URL objects.
//
// We alias #absoluteURL to #toString() here to avoid crashing
// older adapters which do not expect URL objects. We should
// consider removing this support at some point in the future.
function extendURLWithDeprecatedProperties(url) {
Object.defineProperties(url, deprecatedLocationPropertyDescriptors);
}
const deprecatedLocationPropertyDescriptors = {
absoluteURL: {
get() {
return this.toString()
}
}
};
const session = new Session(recentRequests);
// Rename `navigator` to avoid shadowing `window.navigator`
const { cache, navigator: sessionNavigator } = 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);
}
/**
* Sets the delay after which the progress bar will appear during navigation.
*
* The progress bar appears after 500ms by default.
*
* Note that this method has no effect when used with the iOS or Android
* adapters.
*
* @param delay Time to delay in milliseconds
*/
function setProgressBarDelay(delay) {
console.warn(
"Please replace `Turbo.setProgressBarDelay(delay)` with `Turbo.config.drive.progressBarDelay = delay`. The top-level function is deprecated and will be removed in a future version of Turbo.`"
);
config.drive.progressBarDelay = delay;
}
function setConfirmMethod(confirmMethod) {
console.warn(
"Please replace `Turbo.setConfirmMethod(confirmMethod)` with `Turbo.config.forms.confirm = confirmMethod`. The top-level function is deprecated and will be removed in a future version of Turbo.`"
);
config.forms.confirm = confirmMethod;
}
function setFormMode(mode) {
console.warn(
"Please replace `Turbo.setFormMode(mode)` with `Turbo.config.forms.mode = mode`. The top-level function is deprecated and will be removed in a future version of Turbo.`"
);
config.forms.mode = mode;
}
/**
* Morph the state of the currentBody based on the attributes and contents of
* the newBody. Morphing body elements may dispatch turbo:morph,
* turbo:before-morph-element, turbo:before-morph-attribute, and
* turbo:morph-element events.
*
* @param currentBody HTMLBodyElement destination of morphing changes
* @param newBody HTMLBodyElement source of morphing changes
*/
function morphBodyElements(currentBody, newBody) {
MorphingPageRenderer.renderElement(currentBody, newBody);
}
/**
* Morph the child elements of the currentFrame based on the child elements of
* the newFrame. Morphing turbo-frame elements may dispatch turbo:before-frame-morph,
* turbo:before-morph-element, turbo:before-morph-attribute, and
* turbo:morph-element events.
*
* @param currentFrame FrameElement destination of morphing children changes
* @param newFrame FrameElement source of morphing children changes
*/
function morphTurboFrameElements(currentFrame, newFrame) {
MorphingFrameRenderer.renderElement(currentFrame, newFrame);
}
var Turbo = /*#__PURE__*/Object.freeze({
__proto__: null,
PageRenderer: PageRenderer,
PageSnapshot: PageSnapshot,
FrameRenderer: FrameRenderer,
fetch: fetchWithTurboHeaders,
config: config,
session: session,
cache: cache,
navigator: sessionNavigator,
start: start,
registerAdapter: registerAdapter,
visit: visit,
connectStreamSource: connectStreamSource,
disconnectStreamSource: disconnectStreamSource,
renderStreamMessage: renderStreamMessage,
setProgressBarDelay: setProgressBarDelay,
setConfirmMethod: setConfirmMethod,
setFormMode: setFormMode,
morphBodyElements: morphBodyElements,
morphTurboFrameElements: morphTurboFrameElements,
morphChildren: morphChildren,
morphElements: morphElements
});
class TurboFrameMissingError extends Error {}
class FrameController {
fetchResponseLoaded = (_fetchResponse) => Promise.resolve()
#currentFetchRequest = null
#resolveVisitPromise = () => {}
#connected = false
#hasBeenLoaded = false
#ignoredAttributes = new Set()
#shouldMorphFrame = false
action = null
constructor(element) {
this.element = element;
this.view = new FrameView(this, this.element);
this.appearanceObserver = new AppearanceObserver(this, this.element);
this.formLinkClickObserver = new FormLinkClickObserver(this, this.element);
this.linkInterceptor = new LinkInterceptor(this, this.element);
this.restorationIdentifier = uuid();
this.formSubmitObserver = new FormSubmitObserver(this, this.element);
}
// Frame delegate
connect() {
if (!this.#connected) {
this.#connected = true;
if (this.loadingStyle == FrameLoadingStyle.lazy) {
this.appearanceObserver.start();
} else {
this.#loadSourceURL();
}
this.formLinkClickObserver.start();
this.linkInterceptor.start();
this.formSubmitObserver.start();
}
}
disconnect() {
if (this.#connected) {
this.#connected = false;
this.appearanceObserver.stop();
this.formLinkClickObserver.stop();
this.linkInterceptor.stop();
this.formSubmitObserver.stop();
if (!this.element.hasAttribute("recurse")) {
this.#currentFetchRequest?.cancel();
}
}
}
disabledChanged() {
if (this.disabled) {
this.#currentFetchRequest?.cancel();
} else if (this.loadingStyle == FrameLoadingStyle.eager) {
this.#loadSourceURL();
}
}
sourceURLChanged() {
if (this.#isIgnoringChangesTo("src")) return
if (!this.sourceURL) {
this.#currentFetchRequest?.cancel();
}
if (this.element.isConnected) {
this.complete = false;
}
if (this.loadingStyle == FrameLoadingStyle.eager || this.#hasBeenLoaded) {
this.#loadSourceURL();
}
}
sourceURLReloaded() {
const { refresh, src } = this.element;
this.#shouldMorphFrame = src && refresh === "morph";
this.element.removeAttribute("complete");
this.element.src = null;
this.element.src = src;
return this.element.loaded
}
loadingStyleChanged() {
if (this.loadingStyle == FrameLoadingStyle.lazy) {
this.appearanceObserver.start();
} else {
this.appearanceObserver.stop();
this.#loadSourceURL();
}
}
async #loadSourceURL() {
if (this.enabled && this.isActive && !this.complete && this.sourceURL) {
this.element.loaded = this.#visit(expandURL(this.sourceURL));
this.appearanceObserver.stop();
await this.element.loaded;
this.#hasBeenLoaded = true;
}
}
async loadResponse(fetchResponse) {
if (fetchResponse.redirected || (fetchResponse.succeeded && fetchResponse.isHTML)) {
this.sourceURL = fetchResponse.response.url;
}
try {
const html = await fetchResponse.responseHTML;
if (html) {
const document = parseHTMLDocument(html);
const pageSnapshot = PageSnapshot.fromDocument(document);
if (pageSnapshot.isVisitable) {
await this.#loadFrameResponse(fetchResponse, document);
} else {
await this.#handleUnvisitableFrameResponse(fetchResponse);
}
}
} finally {
this.#shouldMorphFrame = false;
this.fetchResponseLoaded = () => Promise.resolve();
}
}
// Appearance observer delegate
elementAppearedInViewport(element) {
this.proposeVisitIfNavigatedWithAction(element, getVisitAction(element));
this.#loadSourceURL();
}
// Form link click observer delegate
willSubmitFormLinkToLocation(link) {
return this.#shouldInterceptNavigation(link)
}
submittedFormLinkToLocation(link, _location, form) {
const frame = this.#findFrameElement(link);
if (frame) form.setAttribute("data-turbo-frame", frame.id);
}
// Link interceptor delegate
shouldInterceptLinkClick(element, _location, _event) {
return this.#shouldInterceptNavigation(element)
}
linkClickIntercepted(element, location) {
this.#navigateFrame(element, location);
}
// Form submit observer delegate
willSubmitForm(element, submitter) {
return element.closest("turbo-frame") == this.element && this.#shouldInterceptNavigation(element, submitter)
}
formSubmitted(element, submitter) {
if (this.formSubmission) {
this.formSubmission.stop();
}
this.formSubmission = new FormSubmission(this, element, submitter);
const { fetchRequest } = this.formSubmission;
const frame = this.#findFrameElement(element, submitter);
this.prepareRequest(fetchRequest, frame);
this.formSubmission.start();
}
// Fetch request delegate
prepareRequest(request, frame = this) {
request.headers["Turbo-Frame"] = frame.id;
if (this.currentNavigationElement?.hasAttribute("data-turbo-stream")) {
request.acceptResponseType(StreamMessage.contentType);
}
}
requestStarted(_request) {
markAsBusy(this.element);
}
requestPreventedHandlingResponse(_request, _response) {
this.#resolveVisitPromise();
}
async requestSucceededWithResponse(request, response) {
await this.loadResponse(response);
this.#resolveVisitPromise();
}
async requestFailedWithResponse(request, response) {
await this.loadResponse(response);
this.#resolveVisitPromise();
}
requestErrored(request, error) {
console.error(error);
this.#resolveVisitPromise();
}
requestFinished(_request) {
clearBusyState(this.element);
}
// Form submission delegate
formSubmissionStarted({ formElement }) {
markAsBusy(formElement, this.#findFrameElement(formElement));
}
formSubmissionSucceededWithResponse(formSubmission, response) {
const frame = this.#findFrameElement(formSubmission.formElement, formSubmission.submitter);
frame.delegate.proposeVisitIfNavigatedWithAction(frame, getVisitAction(formSubmission.submitter, formSubmission.formElement, frame));
frame.delegate.loadResponse(response);
if (!formSubmission.isSafe) {
session.clearCache();
}
}
formSubmissionFailedWithResponse(formSubmission, fetchResponse) {
this.element.delegate.loadResponse(fetchResponse);
session.clearCache();
}
formSubmissionErrored(formSubmission, error) {
console.error(error);
}
formSubmissionFinished({ formElement }) {
clearBusyState(formElement, this.#findFrameElement(formElement));
}
// View delegate
allowsImmediateRender({ element: newFrame }, options) {
const event = dispatch("turbo:before-frame-render", {
target: this.element,
detail: { newFrame, ...options },
cancelable: true
});
const {
defaultPrevented,
detail: { render }
} = event;
if (this.view.renderer && render) {
this.view.renderer.renderElement = render;
}
return !defaultPrevented
}
viewRenderedSnapshot(_snapshot, _isPreview, _renderMethod) {}
preloadOnLoadLinksForView(element) {
session.preloadOnLoadLinksForView(element);
}
viewInvalidated() {}
// Frame renderer delegate
willRenderFrame(currentElement, _newElement) {
this.previousFrameElement = currentElement.cloneNode(true);
}
visitCachedSnapshot = ({ element }) => {
const frame = element.querySelector("#" + this.element.id);
if (frame && this.previousFrameElement) {
frame.replaceChildren(...this.previousFrameElement.children);
}
delete this.previousFrameElement;
}
// Private
async #loadFrameResponse(fetchResponse, document) {
const newFrameElement = await this.extractForeignFrameElement(document.body);
const rendererClass = this.#shouldMorphFrame ? MorphingFrameRenderer : FrameRenderer;
if (newFrameElement) {
const snapshot = new Snapshot(newFrameElement);
const renderer = new rendererClass(this, this.view.snapshot, snapshot, false, false);
if (this.view.renderPromise) await this.view.renderPromise;
this.changeHistory();
await this.view.render(renderer);
this.complete = true;
session.frameRendered(fetchResponse, this.element);
session.frameLoaded(this.element);
await this.fetchResponseLoaded(fetchResponse);
} else if (this.#willHandleFrameMissingFromResponse(fetchResponse)) {
this.#handleFrameMissingFromResponse(fetchResponse);
}
}
async #visit(url) {
const request = new FetchRequest(this, FetchMethod.get, url, new URLSearchParams(), this.element);
this.#currentFetchRequest?.cancel();
this.#currentFetchRequest = request;
return new Promise((resolve) => {
this.#resolveVisitPromise = () => {
this.#resolveVisitPromise = () => {};
this.#currentFetchRequest = null;
resolve();
};
request.perform();
})
}
#navigateFrame(element, url, submitter) {
const frame = this.#findFrameElement(element, submitter);
frame.delegate.proposeVisitIfNavigatedWithAction(frame, getVisitAction(submitter, element, frame));
this.#withCurrentNavigationElement(element, () => {
frame.src = url;
});
}
proposeVisitIfNavigatedWithAction(frame, action = null) {
this.action = action;
if (this.action) {
const pageSnapshot = PageSnapshot.fromElement(frame).clone();
const { visitCachedSnapshot } = frame.delegate;
frame.delegate.fetchResponseLoaded = async (fetchResponse) => {
if (frame.src) {
const { statusCode, redirected } = fetchResponse;
const responseHTML = await fetchResponse.responseHTML;
const response = { statusCode, redirected, responseHTML };
const options = {
response,
visitCachedSnapshot,
willRender: false,
updateHistory: false,
restorationIdentifier: this.restorationIdentifier,
snapshot: pageSnapshot
};
if (this.action) options.action = this.action;
session.visit(frame.src, options);
}
};
}
}
changeHistory() {
if (this.action) {
const method = getHistoryMethodForAction(this.action);
session.history.update(method, expandURL(this.element.src || ""), this.restorationIdentifier);
}
}
async #handleUnvisitableFrameResponse(fetchResponse) {
console.warn(
`The response (${fetchResponse.statusCode}) from <turbo-frame id="${this.element.id}"> is performing a full page visit due to turbo-visit-control.`
);
await this.#visitResponse(fetchResponse.response);
}
#willHandleFrameMissingFromResponse(fetchResponse) {
this.element.setAttribute("complete", "");
const response = fetchResponse.response;
const visit = async (url, options) => {
if (url instanceof Response) {
this.#visitResponse(url);
} else {
session.visit(url, options);
}
};
const event = dispatch("turbo:frame-missing", {
target: this.element,
detail: { response, visit },
cancelable: true
});
return !event.defaultPrevented
}
#handleFrameMissingFromResponse(fetchResponse) {
this.view.missing();
this.#throwFrameMissingError(fetchResponse);
}
#throwFrameMissingError(fetchResponse) {
const message = `The response (${fetchResponse.statusCode}) did not contain the expected <turbo-frame id="${this.element.id}"> and will be ignored. To perform a full page visit instead, set turbo-visit-control to reload.`;
throw new TurboFrameMissingError(message)
}
async #visitResponse(response) {
const wrapped = new FetchResponse(response);
const responseHTML = await wrapped.responseHTML;
const { location, redirected, statusCode } = wrapped;
return session.visit(location, { response: { redirected, statusCode, responseHTML } })
}
#findFrameElement(element, submitter) {
const id = getAttribute("data-turbo-frame", submitter, element) || this.element.getAttribute("target");
const target = this.#getFrameElementById(id);
return target instanceof FrameElement ? target : 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 = this.#getFrameElementById(id);
if (frameElement) {
return !frameElement.disabled
} else if (id == "_parent") {
return false
}
}
if (!session.elementIsNavigatable(element)) {
return false
}
if (submitter && !session.elementIsNavigatable(submitter)) {
return false
}
return true
}
// Computed properties
get id() {
return this.element.id
}
get disabled() {
return this.element.disabled
}
get enabled() {
return !this.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;
}
#getFrameElementById(id) {
if (id != null) {
const element = id === "_parent" ?
this.element.parentElement.closest("turbo-frame") :
document.getElementById(id);
if (element instanceof FrameElement) {
return element
}
}
}
}
function activateElement(element, currentURL) {
if (element) {
const src = element.getAttribute("src");
if (src != null && currentURL != null && urlsAreEqual(src, currentURL)) {
throw new Error(`Matching <turbo-frame id="${element.id}"> element has a source URL which references itself`)
}
if (element.ownerDocument !== document) {
element = document.importNode(element, true);
}
if (element instanceof FrameElement) {
element.connectedCallback();
element.disconnectedCallback();
return element
}
}
}
const StreamActions = {
after() {
this.removeDuplicateTargetSiblings();
this.targetElements.forEach((e) => e.parentElement?.insertBefore(this.templateContent, e.nextSibling));
},
append() {
this.removeDuplicateTargetChildren();
this.targetElements.forEach((e) => e.append(this.templateContent));
},
before() {
this.removeDuplicateTargetSiblings();
this.targetElements.forEach((e) => e.parentElement?.insertBefore(this.templateContent, e));
},
prepend() {
this.removeDuplicateTargetChildren();
this.targetElements.forEach((e) => e.prepend(this.templateContent));
},
remove() {
this.targetElements.forEach((e) => e.remove());
},
replace() {
const method = this.getAttribute("method");
this.targetElements.forEach((targetElement) => {
if (method === "morph") {
morphElements(targetElement, this.templateContent);
} else {
targetElement.replaceWith(this.templateContent);
}
});
},
update() {
const method = this.getAttribute("method");
this.targetElements.forEach((targetElement) => {
if (method === "morph") {
morphChildren(targetElement, this.templateContent);
} else {
targetElement.innerHTML = "";
targetElement.append(this.templateContent);
}
});
},
refresh() {
const method = this.getAttribute("method");
const requestId = this.requestId;
const scroll = this.getAttribute("scroll");
session.refresh(this.baseURI, { method, requestId, scroll });
}
};
// <turbo-stream action=replace target=id><template>...
/**
* Renders updates to the page from a stream of messages.
*
* Using the `action` attribute, this can be configured one of eight ways:
*
* - `after` - inserts the result after the target
* - `append` - appends the result to the target
* - `before` - inserts the result before the target
* - `prepend` - prepends the result to the target
* - `refresh` - initiates a page refresh
* - `remove` - removes the target
* - `replace` - replaces the outer HTML of the target
* - `update` - replaces the inner HTML of the target
*
* @customElement turbo-stream
* @example
* <turbo-stream action="append" target="dom_id">
* <template>
* Content to append to target designated with the dom_id.
* </template>
* </turbo-stream>
*/
class StreamElement extends HTMLElement {
static async renderElement(newElement) {
await newElement.performAction();
}
async connectedCallback() {
try {
await this.render();
} catch (error) {
console.error(error);
} finally {
this.disconnect();
}
}
async render() {
return (this.renderPromise ??= (async () => {
const event = this.beforeRenderEvent;
if (this.dispatchEvent(event)) {
await nextRepaint();
await event.detail.render(this);
}
})())
}
disconnect() {
try {
this.remove();
// eslint-disable-next-line no-empty
} catch {}
}
/**
* Removes duplicate children (by ID)
*/
removeDuplicateTargetChildren() {
this.duplicateChildren.forEach((c) => c.remove());
}
/**
* Gets the list of duplicate children (i.e. those with the same ID)
*/
get duplicateChildren() {
const existingChildren = this.targetElements.flatMap((e) => [...e.children]).filter((c) => !!c.getAttribute("id"));
const newChildrenIds = [...(this.templateContent?.children || [])].filter((c) => !!c.getAttribute("id")).map((c) => c.getAttribute("id"));
return existingChildren.filter((c) => newChildrenIds.includes(c.getAttribute("id")))
}
/**
* Removes duplicate siblings (by ID)
*/
removeDuplicateTargetSiblings() {
this.duplicateSiblings.forEach((c) => c.remove());
}
/**
* Gets the list of duplicate siblings (i.e. those with the same ID)
*/
get duplicateSiblings() {
const existingChildren = this.targetElements.flatMap((e) => [...e.parentElement.children]).filter((c) => !!c.id);
const newChildrenIds = [...(this.templateContent?.children || [])].filter((c) => !!c.id).map((c) => c.id);
return existingChildren.filter((c) => newChildrenIds.includes(c.id))
}
/**
* Gets the action function to be performed.
*/
get performAction() {
if (this.action) {
const actionFunction = StreamActions[this.action];
if (actionFunction) {
return actionFunction
}
this.#raise("unknown action");
}
this.#raise("action attribute is missing");
}
/**
* Gets the target elements which the template will be rendered to.
*/
get targetElements() {
if (this.target) {
return this.targetElementsById
} else if (this.targets) {
return this.targetElementsByQuery
} else {
this.#raise("target or targets attribute is missing");
}
}
/**
* Gets the contents of the main `<template>`.
*/
get templateContent() {
return this.templateElement.content.cloneNode(true)
}
/**
* Gets the main `<template>` used for rendering
*/
get templateElement() {
if (this.firstElementChild === null) {
const template = this.ownerDocument.createElement("template");
this.appendChild(template);
return template
} else if (this.firstElementChild instanceof HTMLTemplateElement) {
return this.firstElementChild
}
this.#raise("first child element must be a <template> element");
}
/**
* Gets the current action.
*/
get action() {
return this.getAttribute("action")
}
/**
* Gets the current target (an element ID) to which the result will
* be rendered.
*/
get target() {
return this.getAttribute("target")
}
/**
* Gets the current "targets" selector (a CSS selector)
*/
get targets() {
return this.getAttribute("targets")
}
/**
* Reads the request-id attribute
*/
get requestId() {
return this.getAttribute("request-id")
}
#raise(message) {
throw new Error(`${this.description}: ${message}`)
}
get description() {
return (this.outerHTML.match(/<[^>]+>/) ?? [])[0] ?? "<turbo-stream>"
}
get beforeRenderEvent() {
return new CustomEvent("turbo:before-stream-render", {
bubbles: true,
cancelable: true,
detail: { newStream: this, render: StreamElement.renderElement }
})
}
get targetElementsById() {
const element = this.ownerDocument?.getElementById(this.target);
if (element !== null) {
return [element]
} else {
return []
}
}
get targetElementsByQuery() {
const elements = this.ownerDocument?.querySelectorAll(this.targets);
if (elements.length !== 0) {
return Array.prototype.slice.call(elements)
} else {
return []
}
}
}
class StreamSourceElement extends HTMLElement {
streamSource = null
connectedCallback() {
this.streamSource = this.src.match(/^ws{1,2}:/) ? new WebSocket(this.src) : new EventSource(this.src);
connectStreamSource(this.streamSource);
}
disconnectedCallback() {
if (this.streamSource) {
this.streamSource.close();
disconnectStreamSource(this.streamSource);
}
}
get src() {
return this.getAttribute("src") || ""
}
}
FrameElement.delegateConstructor = FrameController;
if (customElements.get("turbo-frame") === undefined) {
customElements.define("turbo-frame", FrameElement);
}
if (customElements.get("turbo-stream") === undefined) {
customElements.define("turbo-stream", StreamElement);
}
if (customElements.get("turbo-stream-source") === undefined) {
customElements.define("turbo-stream-source", StreamSourceElement);
}
(() => {
const scriptElement = document.currentScript;
if (!scriptElement) return
if (scriptElement.hasAttribute("data-turbo-suppress-warning")) return
let element = scriptElement.parentElement;
while (element) {
if (element == document.body) {
return console.warn(
unindent`
You are loading Turbo from a <script> element inside the <body> element. This is probably not what you meant to do!
Load your applications JavaScript bundle inside the <head> element instead. <script> elements in <body> are evaluated with each page change.
For more information, see: https://turbo.hotwired.dev/handbook/building#working-with-script-elements
——
Suppress this warning by adding a "data-turbo-suppress-warning" attribute to: %s
`,
scriptElement.outerHTML
)
}
element = element.parentElement;
}
})();
window.Turbo = { ...Turbo, StreamActions };
start();
export { FetchEnctype, FetchMethod, FetchRequest, FetchResponse, FrameElement, FrameLoadingStyle, FrameRenderer, PageRenderer, PageSnapshot, StreamActions, StreamElement, StreamSourceElement, cache, config, connectStreamSource, disconnectStreamSource, fetchWithTurboHeaders as fetch, fetchEnctypeFromString, fetchMethodFromString, isSafe, morphBodyElements, morphChildren, morphElements, morphTurboFrameElements, sessionNavigator as navigator, registerAdapter, renderStreamMessage, session, setConfirmMethod, setFormMode, setProgressBarDelay, start, visit };