/*!
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
*
*
* Show all expanded messages in this frame.
*
*
*
*
*/
class FrameElement extends HTMLElement {
static delegateConstructor = undefined
loaded = Promise.resolve()
static get observedAttributes() {
return ["disabled", "loading", "src"]
}
constructor() {
super();
this.delegate = new FrameElement.delegateConstructor(this);
}
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 = `Content missing`;
}
get snapshot() {
return new Snapshot(this.element)
}
}
class LinkInterceptor {
constructor(delegate, element) {
this.delegate = delegate;
this.element = element;
}
start() {
this.element.addEventListener("click", this.clickBubbled);
document.addEventListener("turbo:click", this.linkClicked);
document.addEventListener("turbo:before-visit", this.willVisit);
}
stop() {
this.element.removeEventListener("click", this.clickBubbled);
document.removeEventListener("turbo:click", this.linkClicked);
document.removeEventListener("turbo:before-visit", this.willVisit);
}
clickBubbled = (event) => {
if (this.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} persistentIds
* @property {Map>} idMap
*/
/**
* @typedef {Function} Morph
*
* @param {Element | Document} oldNode
* @param {Element | Node | HTMLCollection | Node[] | string | null} newContent
* @param {Config} [config]
* @returns {undefined | Node[]}
*/
// base IIFE to define idiomorph
/**
*
* @type {{defaults: ConfigInternal, morph: Morph}}
*/
var Idiomorph = (function () {
/**
* @typedef {object} MorphContext
*
* @property {Element} target
* @property {Element} newContent
* @property {ConfigInternal} config
* @property {ConfigInternal['morphStyle']} morphStyle
* @property {ConfigInternal['ignoreActive']} ignoreActive
* @property {ConfigInternal['ignoreActiveValue']} ignoreActiveValue
* @property {ConfigInternal['restoreFocus']} restoreFocus
* @property {Map>} idMap
* @property {Set} persistentIds
* @property {ConfigInternal['callbacks']} callbacks
* @property {ConfigInternal['head']} head
* @property {HTMLDivElement} pantry
* @property {Element[]} activeElementAndParents
*/
//=============================================================================
// AND NOW IT BEGINS...
//=============================================================================
const noOp = () => {};
/**
* Default configuration values, updatable by users now
* @type {ConfigInternal}
*/
const defaults = {
morphStyle: "outerHTML",
callbacks: {
beforeNodeAdded: noOp,
afterNodeAdded: noOp,
beforeNodeMorphed: noOp,
afterNodeMorphed: noOp,
beforeNodeRemoved: noOp,
afterNodeRemoved: noOp,
beforeAttributeUpdated: noOp,
},
head: {
style: "merge",
shouldPreserve: (elt) => elt.getAttribute("im-preserve") === "true",
shouldReAppend: (elt) => elt.getAttribute("im-re-append") === "true",
shouldRemove: noOp,
afterHeadMorphed: noOp,
},
restoreFocus: true,
};
/**
* Core idiomorph function for morphing one DOM tree to another
*
* @param {Element | Document} oldNode
* @param {Element | Node | HTMLCollection | Node[] | string | null} newContent
* @param {Config} [config]
* @returns {Promise | Node[]}
*/
function morph(oldNode, newContent, config = {}) {
oldNode = normalizeElement(oldNode);
const newNode = normalizeParent(newContent);
const ctx = createMorphContext(oldNode, newNode, config);
const morphedNodes = saveAndRestoreFocus(ctx, () => {
return withHeadBlocking(
ctx,
oldNode,
newNode,
/** @param {MorphContext} ctx */ (ctx) => {
if (ctx.morphStyle === "innerHTML") {
morphChildren(ctx, oldNode, newNode);
return Array.from(oldNode.childNodes);
} else {
return morphOuterHTML(ctx, oldNode, newNode);
}
},
);
});
ctx.pantry.remove();
return morphedNodes;
}
/**
* Morph just the outerHTML of the oldNode to the newContent
* We have to be careful because the oldNode could have siblings which need to be untouched
* @param {MorphContext} ctx
* @param {Element} oldNode
* @param {Element} newNode
* @returns {Node[]}
*/
function morphOuterHTML(ctx, oldNode, newNode) {
const oldParent = normalizeParent(oldNode);
morphChildren(
ctx,
oldParent,
newNode,
// these two optional params are the secret sauce
oldNode, // start point for iteration
oldNode.nextSibling, // end point for iteration
);
// this is safe even with siblings, because normalizeParent returns a SlicedParentNode if needed.
return Array.from(oldParent.childNodes);
}
/**
* @param {MorphContext} ctx
* @param {Function} fn
* @returns {Promise | Node[]}
*/
function saveAndRestoreFocus(ctx, fn) {
if (!ctx.config.restoreFocus) return fn();
let activeElement =
/** @type {HTMLInputElement|HTMLTextAreaElement|null} */ (
document.activeElement
);
// don't bother if the active element is not an input or textarea
if (
!(
activeElement instanceof HTMLInputElement ||
activeElement instanceof HTMLTextAreaElement
)
) {
return fn();
}
const { id: activeElementId, selectionStart, selectionEnd } = activeElement;
const results = fn();
if (
activeElementId &&
activeElementId !== document.activeElement?.getAttribute("id")
) {
activeElement = ctx.target.querySelector(`[id="${activeElementId}"]`);
activeElement?.focus();
}
if (activeElement && !activeElement.selectionEnd && selectionEnd) {
activeElement.setSelectionRange(selectionStart, selectionEnd);
}
return results;
}
const morphChildren = (function () {
/**
* This is the core algorithm for matching up children. The idea is to use id sets to try to match up
* nodes as faithfully as possible. We greedily match, which allows us to keep the algorithm fast, but
* by using id sets, we are able to better match up with content deeper in the DOM.
*
* Basic algorithm:
* - for each node in the new content:
* - search self and siblings for an id set match, falling back to a soft match
* - if match found
* - remove any nodes up to the match:
* - pantry persistent nodes
* - delete the rest
* - morph the match
* - elsif no match found, and node is persistent
* - find its match by querying the old root (future) and pantry (past)
* - move it and its children here
* - morph it
* - else
* - create a new node from scratch as a last result
*
* @param {MorphContext} ctx the merge context
* @param {Element} oldParent the old content that we are merging the new content into
* @param {Element} newParent the parent element of the new content
* @param {Node|null} [insertionPoint] the point in the DOM we start morphing at (defaults to first child)
* @param {Node|null} [endPoint] the point in the DOM we stop morphing at (defaults to after last child)
*/
function morphChildren(
ctx,
oldParent,
newParent,
insertionPoint = null,
endPoint = null,
) {
// normalize
if (
oldParent instanceof HTMLTemplateElement &&
newParent instanceof HTMLTemplateElement
) {
// @ts-ignore we can pretend the DocumentFragment is an Element
oldParent = oldParent.content;
// @ts-ignore ditto
newParent = newParent.content;
}
insertionPoint ||= oldParent.firstChild;
// run through all the new content
for (const newChild of newParent.childNodes) {
// once we reach the end of the old parent content skip to the end and insert the rest
if (insertionPoint && insertionPoint != endPoint) {
const bestMatch = findBestMatch(
ctx,
newChild,
insertionPoint,
endPoint,
);
if (bestMatch) {
// if the node to morph is not at the insertion point then remove/move up to it
if (bestMatch !== insertionPoint) {
removeNodesBetween(ctx, insertionPoint, bestMatch);
}
morphNode(bestMatch, newChild, ctx);
insertionPoint = bestMatch.nextSibling;
continue;
}
}
// if the matching node is elsewhere in the original content
if (newChild instanceof Element) {
// we can pretend the id is non-null because the next `.has` line will reject it if not
const newChildId = /** @type {String} */ (
newChild.getAttribute("id")
);
if (ctx.persistentIds.has(newChildId)) {
// move it and all its children here and morph
const movedChild = moveBeforeById(
oldParent,
newChildId,
insertionPoint,
ctx,
);
morphNode(movedChild, newChild, ctx);
insertionPoint = movedChild.nextSibling;
continue;
}
}
// last resort: insert the new node from scratch
const insertedNode = createNode(
oldParent,
newChild,
insertionPoint,
ctx,
);
// could be null if beforeNodeAdded prevented insertion
if (insertedNode) {
insertionPoint = insertedNode.nextSibling;
}
}
// remove any remaining old nodes that didn't match up with new content
while (insertionPoint && insertionPoint != endPoint) {
const tempNode = insertionPoint;
insertionPoint = insertionPoint.nextSibling;
removeNode(ctx, tempNode);
}
}
/**
* This performs the action of inserting a new node while handling situations where the node contains
* elements with persistent ids and possible state info we can still preserve by moving in and then morphing
*
* @param {Element} oldParent
* @param {Node} newChild
* @param {Node|null} insertionPoint
* @param {MorphContext} ctx
* @returns {Node|null}
*/
function createNode(oldParent, newChild, insertionPoint, ctx) {
if (ctx.callbacks.beforeNodeAdded(newChild) === false) return null;
if (ctx.idMap.has(newChild)) {
// node has children with ids with possible state so create a dummy elt of same type and apply full morph algorithm
const newEmptyChild = document.createElement(
/** @type {Element} */ (newChild).tagName,
);
oldParent.insertBefore(newEmptyChild, insertionPoint);
morphNode(newEmptyChild, newChild, ctx);
ctx.callbacks.afterNodeAdded(newEmptyChild);
return newEmptyChild;
} else {
// optimisation: no id state to preserve so we can just insert a clone of the newChild and its descendants
const newClonedChild = document.importNode(newChild, true); // importNode to not mutate newParent
oldParent.insertBefore(newClonedChild, insertionPoint);
ctx.callbacks.afterNodeAdded(newClonedChild);
return newClonedChild;
}
}
//=============================================================================
// Matching Functions
//=============================================================================
const findBestMatch = (function () {
/**
* Scans forward from the startPoint to the endPoint looking for a match
* for the node. It looks for an id set match first, then a soft match.
* We abort softmatching if we find two future soft matches, to reduce churn.
* @param {Node} node
* @param {MorphContext} ctx
* @param {Node | null} startPoint
* @param {Node | null} endPoint
* @returns {Node | null}
*/
function findBestMatch(ctx, node, startPoint, endPoint) {
let softMatch = null;
let nextSibling = node.nextSibling;
let siblingSoftMatchCount = 0;
let cursor = startPoint;
while (cursor && cursor != endPoint) {
// soft matching is a prerequisite for id set matching
if (isSoftMatch(cursor, node)) {
if (isIdSetMatch(ctx, cursor, node)) {
return cursor; // found an id set match, we're done!
}
// we haven't yet saved a soft match fallback
if (softMatch === null) {
// the current soft match will hard match something else in the future, leave it
if (!ctx.idMap.has(cursor)) {
// save this as the fallback if we get through the loop without finding a hard match
softMatch = cursor;
}
}
}
if (
softMatch === null &&
nextSibling &&
isSoftMatch(cursor, nextSibling)
) {
// The next new node has a soft match with this node, so
// increment the count of future soft matches
siblingSoftMatchCount++;
nextSibling = nextSibling.nextSibling;
// If there are two future soft matches, block soft matching for this node to allow
// future siblings to soft match. This is to reduce churn in the DOM when an element
// is prepended.
if (siblingSoftMatchCount >= 2) {
softMatch = undefined;
}
}
// if the current node contains active element, stop looking for better future matches,
// because if one is found, this node will be moved to the pantry, reparenting it and thus losing focus
// @ts-ignore pretend cursor is Element rather than Node, we're just testing for array inclusion
if (ctx.activeElementAndParents.includes(cursor)) break;
cursor = cursor.nextSibling;
}
return softMatch || null;
}
/**
*
* @param {MorphContext} ctx
* @param {Node} oldNode
* @param {Node} newNode
* @returns {boolean}
*/
function isIdSetMatch(ctx, oldNode, newNode) {
let oldSet = ctx.idMap.get(oldNode);
let newSet = ctx.idMap.get(newNode);
if (!newSet || !oldSet) return false;
for (const id of oldSet) {
// a potential match is an id in the new and old nodes that
// has not already been merged into the DOM
// But the newNode content we call this on has not been
// merged yet and we don't allow duplicate IDs so it is simple
if (newSet.has(id)) {
return true;
}
}
return false;
}
/**
*
* @param {Node} oldNode
* @param {Node} newNode
* @returns {boolean}
*/
function isSoftMatch(oldNode, newNode) {
// ok to cast: if one is not element, `id` and `tagName` will be undefined and we'll just compare that.
const oldElt = /** @type {Element} */ (oldNode);
const newElt = /** @type {Element} */ (newNode);
return (
oldElt.nodeType === newElt.nodeType &&
oldElt.tagName === newElt.tagName &&
// If oldElt has an `id` with possible state and it doesn't match newElt.id then avoid morphing.
// We'll still match an anonymous node with an IDed newElt, though, because if it got this far,
// its not persistent, and new nodes can't have any hidden state.
// We can't use .id because of form input shadowing, and we can't count on .getAttribute's presence because it could be a document-fragment
(!oldElt.getAttribute?.("id") ||
oldElt.getAttribute?.("id") === newElt.getAttribute?.("id"))
);
}
return findBestMatch;
})();
//=============================================================================
// DOM Manipulation Functions
//=============================================================================
/**
* Gets rid of an unwanted DOM node; strategy depends on nature of its reuse:
* - Persistent nodes will be moved to the pantry for later reuse
* - Other nodes will have their hooks called, and then are removed
* @param {MorphContext} ctx
* @param {Node} node
*/
function removeNode(ctx, node) {
// are we going to id set match this later?
if (ctx.idMap.has(node)) {
// skip callbacks and move to pantry
moveBefore(ctx.pantry, node, null);
} else {
// remove for realsies
if (ctx.callbacks.beforeNodeRemoved(node) === false) return;
node.parentNode?.removeChild(node);
ctx.callbacks.afterNodeRemoved(node);
}
}
/**
* Remove nodes between the start and end nodes
* @param {MorphContext} ctx
* @param {Node} startInclusive
* @param {Node} endExclusive
* @returns {Node|null}
*/
function removeNodesBetween(ctx, startInclusive, endExclusive) {
/** @type {Node | null} */
let cursor = startInclusive;
// remove nodes until the endExclusive node
while (cursor && cursor !== endExclusive) {
let tempNode = /** @type {Node} */ (cursor);
cursor = cursor.nextSibling;
removeNode(ctx, tempNode);
}
return cursor;
}
/**
* Search for an element by id within the document and pantry, and move it using moveBefore.
*
* @param {Element} parentNode - The parent node to which the element will be moved.
* @param {string} id - The ID of the element to be moved.
* @param {Node | null} after - The reference node to insert the element before.
* If `null`, the element is appended as the last child.
* @param {MorphContext} ctx
* @returns {Element} The found element
*/
function moveBeforeById(parentNode, id, after, ctx) {
const target =
/** @type {Element} - will always be found */
(
// ctx.target.id unsafe because of form input shadowing
// ctx.target could be a document fragment which doesn't have `getAttribute`
(ctx.target.getAttribute?.("id") === id && ctx.target) ||
ctx.target.querySelector(`[id="${id}"]`) ||
ctx.pantry.querySelector(`[id="${id}"]`)
);
removeElementFromAncestorsIdMaps(target, ctx);
moveBefore(parentNode, target, after);
return target;
}
/**
* Removes an element from its ancestors' id maps. This is needed when an element is moved from the
* "future" via `moveBeforeId`. Otherwise, its erstwhile ancestors could be mistakenly moved to the
* pantry rather than being deleted, preventing their removal hooks from being called.
*
* @param {Element} element - element to remove from its ancestors' id maps
* @param {MorphContext} ctx
*/
function removeElementFromAncestorsIdMaps(element, ctx) {
// we know id is non-null String, because this function is only called on elements with ids
const id = /** @type {String} */ (element.getAttribute("id"));
/** @ts-ignore - safe to loop in this way **/
while ((element = element.parentNode)) {
let idSet = ctx.idMap.get(element);
if (idSet) {
idSet.delete(id);
if (!idSet.size) {
ctx.idMap.delete(element);
}
}
}
}
/**
* Moves an element before another element within the same parent.
* Uses the proposed `moveBefore` API if available (and working), otherwise falls back to `insertBefore`.
* This is essentialy a forward-compat wrapper.
*
* @param {Element} parentNode - The parent node containing the after element.
* @param {Node} element - The element to be moved.
* @param {Node | null} after - The reference node to insert `element` before.
* If `null`, `element` is appended as the last child.
*/
function moveBefore(parentNode, element, after) {
// @ts-ignore - use proposed moveBefore feature
if (parentNode.moveBefore) {
try {
// @ts-ignore - use proposed moveBefore feature
parentNode.moveBefore(element, after);
} catch (e) {
// fall back to insertBefore as some browsers may fail on moveBefore when trying to move Dom disconnected nodes to pantry
parentNode.insertBefore(element, after);
}
} else {
parentNode.insertBefore(element, after);
}
}
return morphChildren;
})();
//=============================================================================
// Single Node Morphing Code
//=============================================================================
const morphNode = (function () {
/**
* @param {Node} oldNode root node to merge content into
* @param {Node} newContent new content to merge
* @param {MorphContext} ctx the merge context
* @returns {Node | null} the element that ended up in the DOM
*/
function morphNode(oldNode, newContent, ctx) {
if (ctx.ignoreActive && oldNode === document.activeElement) {
// don't morph focused element
return null;
}
if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) {
return oldNode;
}
if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) ; else if (
oldNode instanceof HTMLHeadElement &&
ctx.head.style !== "morph"
) {
// ok to cast: if newContent wasn't also a , it would've got caught in the `!isSoftMatch` branch above
handleHeadElement(
oldNode,
/** @type {HTMLHeadElement} */ (newContent),
ctx,
);
} else {
morphAttributes(oldNode, newContent, ctx);
if (!ignoreValueOfActiveElement(oldNode, ctx)) {
// @ts-ignore newContent can be a node here because .firstChild will be null
morphChildren(ctx, oldNode, newContent);
}
}
ctx.callbacks.afterNodeMorphed(oldNode, newContent);
return oldNode;
}
/**
* syncs the oldNode to the newNode, copying over all attributes and
* inner element state from the newNode to the oldNode
*
* @param {Node} oldNode the node to copy attributes & state to
* @param {Node} newNode the node to copy attributes & state from
* @param {MorphContext} ctx the merge context
*/
function morphAttributes(oldNode, newNode, ctx) {
let type = newNode.nodeType;
// if is an element type, sync the attributes from the
// new node into the new node
if (type === 1 /* element type */) {
const oldElt = /** @type {Element} */ (oldNode);
const newElt = /** @type {Element} */ (newNode);
const oldAttributes = oldElt.attributes;
const newAttributes = newElt.attributes;
for (const newAttribute of newAttributes) {
if (ignoreAttribute(newAttribute.name, oldElt, "update", ctx)) {
continue;
}
if (oldElt.getAttribute(newAttribute.name) !== newAttribute.value) {
oldElt.setAttribute(newAttribute.name, newAttribute.value);
}
}
// iterate backwards to avoid skipping over items when a delete occurs
for (let i = oldAttributes.length - 1; 0 <= i; i--) {
const oldAttribute = oldAttributes[i];
// toAttributes is a live NamedNodeMap, so iteration+mutation is unsafe
// e.g. custom element attribute callbacks can remove other attributes
if (!oldAttribute) continue;
if (!newElt.hasAttribute(oldAttribute.name)) {
if (ignoreAttribute(oldAttribute.name, oldElt, "remove", ctx)) {
continue;
}
oldElt.removeAttribute(oldAttribute.name);
}
}
if (!ignoreValueOfActiveElement(oldElt, ctx)) {
syncInputValue(oldElt, newElt, ctx);
}
}
// sync text nodes
if (type === 8 /* comment */ || type === 3 /* text */) {
if (oldNode.nodeValue !== newNode.nodeValue) {
oldNode.nodeValue = newNode.nodeValue;
}
}
}
/**
* NB: many bothans died to bring us information:
*
* https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js
* https://github.com/choojs/nanomorph/blob/master/lib/morph.jsL113
*
* @param {Element} oldElement the element to sync the input value to
* @param {Element} newElement the element to sync the input value from
* @param {MorphContext} ctx the merge context
*/
function syncInputValue(oldElement, newElement, ctx) {
if (
oldElement instanceof HTMLInputElement &&
newElement instanceof HTMLInputElement &&
newElement.type !== "file"
) {
let newValue = newElement.value;
let oldValue = oldElement.value;
// sync boolean attributes
syncBooleanAttribute(oldElement, newElement, "checked", ctx);
syncBooleanAttribute(oldElement, newElement, "disabled", ctx);
if (!newElement.hasAttribute("value")) {
if (!ignoreAttribute("value", oldElement, "remove", ctx)) {
oldElement.value = "";
oldElement.removeAttribute("value");
}
} else if (oldValue !== newValue) {
if (!ignoreAttribute("value", oldElement, "update", ctx)) {
oldElement.setAttribute("value", newValue);
oldElement.value = newValue;
}
}
// TODO: QUESTION(1cg): this used to only check `newElement` unlike the other branches -- why?
// did I break something?
} else if (
oldElement instanceof HTMLOptionElement &&
newElement instanceof HTMLOptionElement
) {
syncBooleanAttribute(oldElement, newElement, "selected", ctx);
} else if (
oldElement instanceof HTMLTextAreaElement &&
newElement instanceof HTMLTextAreaElement
) {
let newValue = newElement.value;
let oldValue = oldElement.value;
if (ignoreAttribute("value", oldElement, "update", ctx)) {
return;
}
if (newValue !== oldValue) {
oldElement.value = newValue;
}
if (
oldElement.firstChild &&
oldElement.firstChild.nodeValue !== newValue
) {
oldElement.firstChild.nodeValue = newValue;
}
}
}
/**
* @param {Element} oldElement element to write the value to
* @param {Element} newElement element to read the value from
* @param {string} attributeName the attribute name
* @param {MorphContext} ctx the merge context
*/
function syncBooleanAttribute(oldElement, newElement, attributeName, ctx) {
// @ts-ignore this function is only used on boolean attrs that are reflected as dom properties
const newLiveValue = newElement[attributeName],
// @ts-ignore ditto
oldLiveValue = oldElement[attributeName];
if (newLiveValue !== oldLiveValue) {
const ignoreUpdate = ignoreAttribute(
attributeName,
oldElement,
"update",
ctx,
);
if (!ignoreUpdate) {
// update attribute's associated DOM property
// @ts-ignore this function is only used on boolean attrs that are reflected as dom properties
oldElement[attributeName] = newElement[attributeName];
}
if (newLiveValue) {
if (!ignoreUpdate) {
// https://developer.mozilla.org/en-US/docs/Glossary/Boolean/HTML
// this is the correct way to set a boolean attribute to "true"
oldElement.setAttribute(attributeName, "");
}
} else {
if (!ignoreAttribute(attributeName, oldElement, "remove", ctx)) {
oldElement.removeAttribute(attributeName);
}
}
}
}
/**
* @param {string} attr the attribute to be mutated
* @param {Element} element the element that is going to be updated
* @param {"update" | "remove"} updateType
* @param {MorphContext} ctx the merge context
* @returns {boolean} true if the attribute should be ignored, false otherwise
*/
function ignoreAttribute(attr, element, updateType, ctx) {
if (
attr === "value" &&
ctx.ignoreActiveValue &&
element === document.activeElement
) {
return true;
}
return (
ctx.callbacks.beforeAttributeUpdated(attr, element, updateType) ===
false
);
}
/**
* @param {Node} possibleActiveElement
* @param {MorphContext} ctx
* @returns {boolean}
*/
function ignoreValueOfActiveElement(possibleActiveElement, ctx) {
return (
!!ctx.ignoreActiveValue &&
possibleActiveElement === document.activeElement &&
possibleActiveElement !== document.body
);
}
return morphNode;
})();
//=============================================================================
// Head Management Functions
//=============================================================================
/**
* @param {MorphContext} ctx
* @param {Element} oldNode
* @param {Element} newNode
* @param {function} callback
* @returns {Node[] | Promise}
*/
function withHeadBlocking(ctx, oldNode, newNode, callback) {
if (ctx.head.block) {
const oldHead = oldNode.querySelector("head");
const newHead = newNode.querySelector("head");
if (oldHead && newHead) {
const promises = handleHeadElement(oldHead, newHead, ctx);
// when head promises resolve, proceed ignoring the head tag
return Promise.all(promises).then(() => {
const newCtx = Object.assign(ctx, {
head: {
block: false,
ignore: true,
},
});
return callback(newCtx);
});
}
}
// just proceed if we not head blocking
return callback(ctx);
}
/**
* The HEAD tag can be handled specially, either w/ a 'merge' or 'append' style
*
* @param {Element} oldHead
* @param {Element} newHead
* @param {MorphContext} ctx
* @returns {Promise[]}
*/
function handleHeadElement(oldHead, newHead, ctx) {
let added = [];
let removed = [];
let preserved = [];
let nodesToAppend = [];
// put all new head elements into a Map, by their outerHTML
let srcToNewHeadNodes = new Map();
for (const newHeadChild of newHead.children) {
srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild);
}
// for each elt in the current head
for (const currentHeadElt of oldHead.children) {
// If the current head element is in the map
let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML);
let isReAppended = ctx.head.shouldReAppend(currentHeadElt);
let isPreserved = ctx.head.shouldPreserve(currentHeadElt);
if (inNewContent || isPreserved) {
if (isReAppended) {
// remove the current version and let the new version replace it and re-execute
removed.push(currentHeadElt);
} else {
// this element already exists and should not be re-appended, so remove it from
// the new content map, preserving it in the DOM
srcToNewHeadNodes.delete(currentHeadElt.outerHTML);
preserved.push(currentHeadElt);
}
} else {
if (ctx.head.style === "append") {
// we are appending and this existing element is not new content
// so if and only if it is marked for re-append do we do anything
if (isReAppended) {
removed.push(currentHeadElt);
nodesToAppend.push(currentHeadElt);
}
} else {
// if this is a merge, we remove this content since it is not in the new head
if (ctx.head.shouldRemove(currentHeadElt) !== false) {
removed.push(currentHeadElt);
}
}
}
}
// Push the remaining new head elements in the Map into the
// nodes to append to the head tag
nodesToAppend.push(...srcToNewHeadNodes.values());
let promises = [];
for (const newNode of nodesToAppend) {
// TODO: This could theoretically be null, based on type
let newElt = /** @type {ChildNode} */ (
document.createRange().createContextualFragment(newNode.outerHTML)
.firstChild
);
if (ctx.callbacks.beforeNodeAdded(newElt) !== false) {
if (
("href" in newElt && newElt.href) ||
("src" in newElt && newElt.src)
) {
/** @type {(result?: any) => void} */ let resolve;
let promise = new Promise(function (_resolve) {
resolve = _resolve;
});
newElt.addEventListener("load", function () {
resolve();
});
promises.push(promise);
}
oldHead.appendChild(newElt);
ctx.callbacks.afterNodeAdded(newElt);
added.push(newElt);
}
}
// remove all removed elements, after we have appended the new elements to avoid
// additional network requests for things like style sheets
for (const removedElement of removed) {
if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) {
oldHead.removeChild(removedElement);
ctx.callbacks.afterNodeRemoved(removedElement);
}
}
ctx.head.afterHeadMorphed(oldHead, {
added: added,
kept: preserved,
removed: removed,
});
return promises;
}
//=============================================================================
// Create Morph Context Functions
//=============================================================================
const createMorphContext = (function () {
/**
*
* @param {Element} oldNode
* @param {Element} newContent
* @param {Config} config
* @returns {MorphContext}
*/
function createMorphContext(oldNode, newContent, config) {
const { persistentIds, idMap } = createIdMaps(oldNode, newContent);
const mergedConfig = mergeDefaults(config);
const morphStyle = mergedConfig.morphStyle || "outerHTML";
if (!["innerHTML", "outerHTML"].includes(morphStyle)) {
throw `Do not understand how to morph style ${morphStyle}`;
}
return {
target: oldNode,
newContent: newContent,
config: mergedConfig,
morphStyle: morphStyle,
ignoreActive: mergedConfig.ignoreActive,
ignoreActiveValue: mergedConfig.ignoreActiveValue,
restoreFocus: mergedConfig.restoreFocus,
idMap: idMap,
persistentIds: persistentIds,
pantry: createPantry(),
activeElementAndParents: createActiveElementAndParents(oldNode),
callbacks: mergedConfig.callbacks,
head: mergedConfig.head,
};
}
/**
* Deep merges the config object and the Idiomorph.defaults object to
* produce a final configuration object
* @param {Config} config
* @returns {ConfigInternal}
*/
function mergeDefaults(config) {
let finalConfig = Object.assign({}, defaults);
// copy top level stuff into final config
Object.assign(finalConfig, config);
// copy callbacks into final config (do this to deep merge the callbacks)
finalConfig.callbacks = Object.assign(
{},
defaults.callbacks,
config.callbacks,
);
// copy head config into final config (do this to deep merge the head)
finalConfig.head = Object.assign({}, defaults.head, config.head);
return finalConfig;
}
/**
* @returns {HTMLDivElement}
*/
function createPantry() {
const pantry = document.createElement("div");
pantry.hidden = true;
document.body.insertAdjacentElement("afterend", pantry);
return pantry;
}
/**
* @param {Element} oldNode
* @returns {Element[]}
*/
function createActiveElementAndParents(oldNode) {
/** @type {Element[]} */
let activeElementAndParents = [];
let elt = document.activeElement;
if (elt?.tagName !== "BODY" && oldNode.contains(elt)) {
while (elt) {
activeElementAndParents.push(elt);
if (elt === oldNode) break;
elt = elt.parentElement;
}
}
return activeElementAndParents;
}
/**
* Returns all elements with an ID contained within the root element and its descendants
*
* @param {Element} root
* @returns {Element[]}
*/
function findIdElements(root) {
let elements = Array.from(root.querySelectorAll("[id]"));
// root could be a document fragment which doesn't have `getAttribute`
if (root.getAttribute?.("id")) {
elements.push(root);
}
return elements;
}
/**
* A bottom-up algorithm that populates a map of Element -> IdSet.
* The idSet for a given element is the set of all IDs contained within its subtree.
* As an optimzation, we filter these IDs through the given list of persistent IDs,
* because we don't need to bother considering IDed elements that won't be in the new content.
*
* @param {Map>} idMap
* @param {Set} persistentIds
* @param {Element} root
* @param {Element[]} elements
*/
function populateIdMapWithTree(idMap, persistentIds, root, elements) {
for (const elt of elements) {
// we can pretend id is non-null String, because the .has line will reject it immediately if not
const id = /** @type {String} */ (elt.getAttribute("id"));
if (persistentIds.has(id)) {
/** @type {Element|null} */
let current = elt;
// walk up the parent hierarchy of that element, adding the id
// of element to the parent's id set
while (current) {
let idSet = idMap.get(current);
// if the id set doesn't exist, create it and insert it in the map
if (idSet == null) {
idSet = new Set();
idMap.set(current, idSet);
}
idSet.add(id);
if (current === root) break;
current = current.parentElement;
}
}
}
}
/**
* This function computes a map of nodes to all ids contained within that node (inclusive of the
* node). This map can be used to ask if two nodes have intersecting sets of ids, which allows
* for a looser definition of "matching" than tradition id matching, and allows child nodes
* to contribute to a parent nodes matching.
*
* @param {Element} oldContent the old content that will be morphed
* @param {Element} newContent the new content to morph to
* @returns {IdSets}
*/
function createIdMaps(oldContent, newContent) {
const oldIdElements = findIdElements(oldContent);
const newIdElements = findIdElements(newContent);
const persistentIds = createPersistentIds(oldIdElements, newIdElements);
/** @type {Map>} */
let idMap = new Map();
populateIdMapWithTree(idMap, persistentIds, oldContent, oldIdElements);
/** @ts-ignore - if newContent is a duck-typed parent, pass its single child node as the root to halt upwards iteration */
const newRoot = newContent.__idiomorphRoot || newContent;
populateIdMapWithTree(idMap, persistentIds, newRoot, newIdElements);
return { persistentIds, idMap };
}
/**
* This function computes the set of ids that persist between the two contents excluding duplicates
*
* @param {Element[]} oldIdElements
* @param {Element[]} newIdElements
* @returns {Set}
*/
function createPersistentIds(oldIdElements, newIdElements) {
let duplicateIds = new Set();
/** @type {Map} */
let oldIdTagNameMap = new Map();
for (const { id, tagName } of oldIdElements) {
if (oldIdTagNameMap.has(id)) {
duplicateIds.add(id);
} else {
oldIdTagNameMap.set(id, tagName);
}
}
let persistentIds = new Set();
for (const { id, tagName } of newIdElements) {
if (persistentIds.has(id)) {
duplicateIds.add(id);
} else if (oldIdTagNameMap.get(id) === tagName) {
persistentIds.add(id);
}
// skip if tag types mismatch because its not possible to morph one tag into another
}
for (const id of duplicateIds) {
persistentIds.delete(id);
}
return persistentIds;
}
return createMorphContext;
})();
//=============================================================================
// HTML Normalization Functions
//=============================================================================
const { normalizeElement, normalizeParent } = (function () {
/** @type {WeakSet} */
const generatedByIdiomorph = new WeakSet();
/**
*
* @param {Element | Document} content
* @returns {Element}
*/
function normalizeElement(content) {
if (content instanceof Document) {
return content.documentElement;
} else {
return content;
}
}
/**
*
* @param {null | string | Node | HTMLCollection | Node[] | Document & {generatedByIdiomorph:boolean}} newContent
* @returns {Element}
*/
function normalizeParent(newContent) {
if (newContent == null) {
return document.createElement("div"); // dummy parent element
} else if (typeof newContent === "string") {
return normalizeParent(parseContent(newContent));
} else if (
generatedByIdiomorph.has(/** @type {Element} */ (newContent))
) {
// the template tag created by idiomorph parsing can serve as a dummy parent
return /** @type {Element} */ (newContent);
} else if (newContent instanceof Node) {
if (newContent.parentNode) {
// we can't use the parent directly because newContent may have siblings
// that we don't want in the morph, and reparenting might be expensive (TODO is it?),
// so instead we create a fake parent node that only sees a slice of its children.
/** @type {Element} */
return /** @type {any} */ (new SlicedParentNode(newContent));
} else {
// a single node is added as a child to a dummy parent
const dummyParent = document.createElement("div");
dummyParent.append(newContent);
return dummyParent;
}
} else {
// all nodes in the array or HTMLElement collection are consolidated under
// a single dummy parent element
const dummyParent = document.createElement("div");
for (const elt of [...newContent]) {
dummyParent.append(elt);
}
return dummyParent;
}
}
/**
* A fake duck-typed parent element to wrap a single node, without actually reparenting it.
* This is useful because the node may have siblings that we don't want in the morph, and it may also be moved
* or replaced with one or more elements during the morph. This class effectively allows us a window into
* a slice of a node's children.
* "If it walks like a duck, and quacks like a duck, then it must be a duck!" -- James Whitcomb Riley (1849–1916)
*/
class SlicedParentNode {
/** @param {Node} node */
constructor(node) {
this.originalNode = node;
this.realParentNode = /** @type {Element} */ (node.parentNode);
this.previousSibling = node.previousSibling;
this.nextSibling = node.nextSibling;
}
/** @returns {Node[]} */
get childNodes() {
// return slice of realParent's current childNodes, based on previousSibling and nextSibling
const nodes = [];
let cursor = this.previousSibling
? this.previousSibling.nextSibling
: this.realParentNode.firstChild;
while (cursor && cursor != this.nextSibling) {
nodes.push(cursor);
cursor = cursor.nextSibling;
}
return nodes;
}
/**
* @param {string} selector
* @returns {Element[]}
*/
querySelectorAll(selector) {
return this.childNodes.reduce((results, node) => {
if (node instanceof Element) {
if (node.matches(selector)) results.push(node);
const nodeList = node.querySelectorAll(selector);
for (let i = 0; i < nodeList.length; i++) {
results.push(nodeList[i]);
}
}
return results;
}, /** @type {Element[]} */ ([]));
}
/**
* @param {Node} node
* @param {Node} referenceNode
* @returns {Node}
*/
insertBefore(node, referenceNode) {
return this.realParentNode.insertBefore(node, referenceNode);
}
/**
* @param {Node} node
* @param {Node} referenceNode
* @returns {Node}
*/
moveBefore(node, referenceNode) {
// @ts-ignore - use new moveBefore feature
return this.realParentNode.moveBefore(node, referenceNode);
}
/**
* for later use with populateIdMapWithTree to halt upwards iteration
* @returns {Node}
*/
get __idiomorphRoot() {
return this.originalNode;
}
}
/**
*
* @param {string} newContent
* @returns {Node | null | DocumentFragment}
*/
function parseContent(newContent) {
let parser = new DOMParser();
// remove svgs to avoid false-positive matches on head, etc.
let contentWithSvgsRemoved = newContent.replace(
/