refreshless-8.x-1.x-dev/modules/refreshless_turbo/js/refreshless.js
modules/refreshless_turbo/js/refreshless.js
(function(
html, Drupal,
TurboBehaviours, TurboDrupalSettings, TurboProgressBar, TurboScriptManager,
TurboStylesheetManager,
drupalSettings, $, Turbo,
) {
'use strict';
/**
* Name of the attribute we add to cached snapshot bodies to identify them.
*
* @type {String}
*/
const cachedSnapshotAttr = 'data-refreshless-cached-snapshot';
/**
* Our event namespace.
*
* @type {String}
*
* @see https://learn.jquery.com/events/event-basics/#namespacing-events
*/
const eventNamespace = 'refreshless-turbo-core';
/**
* Name of the attribute on elements to be removed from cached pages.
*
* @type {String}
*
* @see https://turbo.hotwired.dev/handbook/building#preparing-the-page-to-be-cached
*/
const temporaryElementAttr = 'data-refreshless-temporary';
/**
* RefreshLess Turbo adapter class.
*/
class RefreshLessTurbo {
/**
* The context element to attach to; usually the <html> element.
*
* @type {HTMLElement}
*/
#context;
/**
* Behaviours class instance; responsible for attaching/detaching.
*
* @type {TurboBehaviours}
*/
#Behaviours;
/**
* drupalSettings updater class instance.
*
* @type {TurboDrupalSettings}
*/
#DrupalSettings;
/**
* Script manager class instance.
*
* @type {TurboScriptManager}
*/
#ScriptManager;
/**
* Progress bar class instance.
*
* @type {TurboProgressBar}
*/
#ProgressBar;
/**
* Stylesheet manager class instance.
*
* @type {TurboStylesheetManager}
*/
#StylesheetManager;
/**
* Hotwire Turbo instance.
*
* @type {Turbo}
*/
#Turbo;
/**
* Whether the current response was a redirect.
*
* Turbo doesn't provide a clean way to detect this in a
* 'turbo:before-render' or 'turbo:render' event listener so we need to
* track whether the most recent response was the result of a redirect to
* avoid triggering a double set of behaviour detach, settings update, and
* behaviour attach.
*
* @type {Boolean}
*
* @see https://www.drupal.org/project/refreshless/issues/3397466
* Documents the problem and potential solutions.
*
* @see this.#skipRender
*/
#isRedirected = false;
/**
* Wether to skip the next render, i.e. detach, settings update, and attach.
*
* We need a second flag in addition to this.#isRedirected to ensure that we
* do still detach behaviours even on a redirect navigation, inelegant
* though it may be.
*
* @type {Boolean}
*
* @see this.#isRedirected
*/
#skipRender = false;
/**
* Whether the current render is a cached preview being displayed.
*
* @type {Boolean}
*/
#isPreview = false;
/**
* Whether the previous render was a cached preview being displayed.
*
* This is useful for actions that must be taken during a before-render that
* occurs after a cached render.
*
* @type {Boolean}
*/
#previousPreview = false;
/**
* Whether the current render is a cached snapshot.
*
* This is similar but slightly different from a cached preview, in that a
* cached snapshot can be both a preview and the final content when Turbo
* performs a restoration visit, i.e. navigating via the browser history.
* In other words, all previews are cached snapshots but not all cached
* snapshots are previews.
*
* @type {Boolean}
*
* @see https://turbo.hotwired.dev/handbook/building#understanding-caching
*/
#isCachedSnapshot = false;
/**
* Whether the current render is a fresh copy replacing a preview.
*
* This is primarily intended for implementing page transitions and other
* tasks during rendering that need to be informed of this specific state.
*
* @type {Boolean}
*/
#isFreshReplacingPreview = false;
/**
* Flag indicating whether refreshless:load event is initial load.
*
* I.e. this is only true on the first triggering of that event and then
* false thereafter.
*
* @type {Boolean}
*/
#initialLoad = true;
constructor(
context, Behaviours, DrupalSettings, ProgressBar, ScriptManager,
StylesheetManager, Turbo,
) {
this.#context = context;
this.#Turbo = Turbo;
this.#Behaviours = new Behaviours(this.#context);
this.#DrupalSettings = new DrupalSettings(this.#context);
this.#ProgressBar = new ProgressBar(this.#context);
this.#ScriptManager = new ScriptManager(this.#context);
this.#StylesheetManager = new StylesheetManager(this.#context);
this.#bindEventHandlers();
}
/**
* Destroy this instance and all its components.
*/
destroy() {
this.#unbindEventHandlers();
this.#Behaviours.destroy();
this.#DrupalSettings.destroy();
this.#ProgressBar.destroy();
this.#ScriptManager.destroy();
this.#StylesheetManager.destroy();
}
/**
* Perform a RefreshLess visit to a location if possible.
*
* This wraps Turbo.visit() as a convenience method, and also adds a check
* to only perform a RefreshLess visit if Turbo.session.drive is not false.
*
* @param {Location|String|URL} location
*
* @param {Object} options
*
* @see https://turbo.hotwired.dev/reference/drive#turbo.visit
* Currently just a wrapper around the Turbo method for convenience.
*
* @todo Provide our own equivalent to Turbo.session.drive and allow
* changing it.
*/
visit(location, options) {
if (this.#Turbo.session.drive === false) {
window.location = location.toString();
return;
}
this.#Turbo.visit(location, options);
}
/**
* Bind all of our event handlers.
*/
#bindEventHandlers() {
// @see https://ambientimpact.com/web/snippets/javascript-template-literal-as-object-property-name
$(this.#context).on({
[`turbo:before-render.${eventNamespace}`]: async (event) => {
await this.#beforeRenderHandler(event);
},
[`turbo:render.${eventNamespace}`]: (event) => {
this.#renderHandler(event);
},
[`turbo:before-fetch-request.${eventNamespace}`]: (event) => {
this.#beforeFetchRequestHandler(event);
},
[`turbo:before-cache.${eventNamespace}`]: async (event) => {
await this.#beforeCacheHandler(event);
},
[`turbo:before-fetch-response.${eventNamespace}`]: (event) => {
this.#beforeFetchResponseHandler(event);
},
[`turbo:fetch-request-error.${eventNamespace}`]: (event) => {
this.#fetchRequestErrorHandler(event);
},
[`turbo:submit-start.${eventNamespace}`]: (event) => {
this.#formSubmitStartHandler(event);
},
[`turbo:submit-end.${eventNamespace}`]: (event) => {
this.#formSubmitEndHandler(event);
},
[`turbo:load.${eventNamespace}`]: (event) => {
this.#loadHandler(event);
},
[`turbo:reload.${eventNamespace}`]: (event) => {
this.#reloadHandler(event);
},
[`turbo:before-prefetch.${eventNamespace}`]: (event) => {
this.#beforePrefetchHandler(event);
},
[`turbo:prefetch-used.${eventNamespace}`]: (event) => {
this.#prefetchUsedHandler(event);
},
[`turbo:click.${eventNamespace}`]: (event) => {
this.#clickHandler(event);
},
});
}
/**
* Unbind all of our event handlers.
*/
#unbindEventHandlers() {
// Why jQuery event namespaces are awesome:
$(this.#context).off(`.${eventNamespace}`);
}
/**
* 'turbo:load' event handler.
*
* @param {jQuery.Event} event
* The event object generated by Turbo.
*/
#loadHandler(event) {
const loadEvent = new CustomEvent(
'refreshless:load', {
detail: $.extend(true, {}, event.detail, {
initial: this.#initialLoad,
}),
},
);
this.#context.dispatchEvent(loadEvent);
this.#initialLoad = false;
}
/**
* 'turbo:before-cache' event handler.
*
* @param {jQuery.Event} event
* The event object generated by Turbo.
*/
async #beforeCacheHandler(event) {
$('body', event.target).attr(cachedSnapshotAttr, true);
const refreshlessEvent = new CustomEvent(
'refreshless:before-cache',
);
this.#context.dispatchEvent(refreshlessEvent);
// Translate our attribute to Turbo's.
$(`[${temporaryElementAttr}]`, event.target).attr(
'data-turbo-temporary', true,
);
await this.#Behaviours.detach(true, 'refreshless:before-cache');
}
/**
* 'turbo:before-prefetch' event handler.
*
* @param {jQuery.Event} event
* The event object generated by Turbo.
*/
#beforePrefetchHandler(event) {
const beforePrefetchEvent = new CustomEvent(
'refreshless:before-prefetch', {
bubbles: true,
cancelable: true,
detail: {url: new URL(event.target.href)}
},
);
event.target.dispatchEvent(beforePrefetchEvent);
if (beforePrefetchEvent.defaultPrevented === true) {
event.preventDefault();
}
}
/**
* 'turbo:prefetch-used' event handler.
*
* @param {jQuery.Event} event
* The event object generated by Turbo.
*/
#prefetchUsedHandler(event) {
/**
* Whether the back-end was notified for this prefetch.
*
* @type {Boolean}
*/
let backendNotified = false;
/**
* A Response from the most recent notification, if any.
*
* @type {Response}
*/
let notifiedResponse;
const notifyBackend = async (
resend = false,
fetchOptions = event.detail.fetchOptions,
) => {
if (backendNotified === true && resend === false) {
return await Promise.resolve(notifiedResponse);
}
fetchOptions = $.extend(true, {
headers: {
[drupalSettings.refreshless.prefetchNotifyHeaderName]: true,
// Since we're not sending this through Turbo, the before fetch
// handler won't be triggered and it won't add our identifying
// header, so add it here.
[drupalSettings.refreshless.headerName]: true,
}
}, fetchOptions);
notifiedResponse = await fetch(event.detail.url, fetchOptions);
backendNotified = true;
return notifiedResponse;
};
const refreshlessEvent = new CustomEvent(
'refreshless:prefetch-used', {
bubbles: true,
detail: {
fetchOptions: event.detail.fetchOptions,
fetchRequest: event.detail.fetchRequest,
notifyBackend: notifyBackend,
backendNotified: backendNotified,
url: event.detail.url,
},
},
);
// Note that we're not using event.target as that's usually the <html>
// element for this event, but the fetch request itself references the
// link that will have been prefetched.
event.detail.fetchRequest.target.dispatchEvent(refreshlessEvent);
}
/**
* 'turbo:click' event handler.
*
* @param {jQuery.Event} event
* The event object generated by Turbo.
*/
#clickHandler(event) {
const clickEvent = new CustomEvent(
'refreshless:click', {
bubbles: true,
cancelable: true,
detail: $.extend({}, event.detail, {
url: new URL(event.detail.url),
}),
},
);
event.target.dispatchEvent(clickEvent);
if (clickEvent.defaultPrevented === true) {
event.preventDefault();
}
}
/**
* 'turbo:before-render' event handler.
*
* @param {jQuery.Event} event
* The event object generated by Turbo.
*
* @see this.#beforeRenderTasks()
*/
async #beforeRenderHandler(event) {
this.#previousPreview = this.#isPreview;
// We want to set this here rather than use a getter method to ensure this
// remains the same throughout the render lifecycle as getting out of
// sync has occasionally been an issue with Turbo.
this.#isPreview = (
typeof $(event.target).attr('data-turbo-preview') !== 'undefined'
);
this.#isCachedSnapshot = (
typeof $(event.detail.newBody).attr(cachedSnapshotAttr) !== 'undefined'
);
this.#isFreshReplacingPreview = (
this.#isPreview === false &&
this.#previousPreview === true &&
this.#isCachedSnapshot === false
);
// If told to skip this render, set the variable back to false and return.
if (this.#skipRender === true) {
this.#skipRender = false;
return;
}
try {
await this.#beforeRenderTasks(event);
} catch (error) {
console.error(error);
// If anything threw an exception somewhere in our event handler, ensure
// that Turbo resumes rendering as a failsafe.
return await event.detail.resume();
}
}
/**
* 'turbo:before-render' event tasks.
*
* @param {jQuery.Event} event
* The event object generated by Turbo.
*/
async #beforeRenderTasks(event) {
// Pause Turbo rendering.
event.preventDefault();
/**
* Zero or more render delaying promises.
*
* This functions as a stack that multiple event subscribers push on to,
* with rendering only resuming once all Promises are resolved or
* rejected.
*
* @type {Promise[]}
*/
const renderDelayPromises = [];
const delay = (executor) => {
// Ensure any errors thrown during the executor function are caught so
// that they don't break rendering nor affect other executors.
const promise = new Promise(async (resolve, reject) => {
try {
await executor(resolve, reject);
} catch (error) {
reject(error);
}
}).catch((error) => {
console.error(error);
});
renderDelayPromises.push(promise);
return promise;
};
const beforeRenderEvent = new CustomEvent(
'refreshless:before-render', {detail: {
delay: delay,
isCachedSnapshot: this.#isCachedSnapshot,
isFreshReplacingPreview: this.#isFreshReplacingPreview,
isPreview: this.#isPreview,
newBody: event.detail.newBody,
previousPreview: this.#previousPreview,
render: event.detail.render,
renderMethod: event.detail.renderMethod,
},
});
this.#context.dispatchEvent(beforeRenderEvent);
await Promise.allSettled(renderDelayPromises);
// Only detach if we're about to render a preview, or if the previous
// render was not a preview.
if (
this.#isPreview === true ||
this.#previousPreview === false
) {
await this.#Behaviours.detach();
}
// Resume Turbo rendering.
return await event.detail.resume();
}
/**
* 'turbo:reload' event handler.
*
* @param {jQuery.Event} event
* The event object generated by Turbo.
*/
#reloadHandler(event) {
const reloadEvent = new CustomEvent(
'refreshless:reload', {detail: event.detail},
);
this.#context.dispatchEvent(reloadEvent);
}
/**
* 'turbo:submit-end' event handler.
*
* @param {jQuery.Event} event
* The event object generated by Turbo.
*/
#formSubmitStartHandler(event) {
const refreshlessEvent = new CustomEvent(
'refreshless:form-submit-start', {detail: event.detail},
);
this.#context.dispatchEvent(refreshlessEvent);
}
/**
* 'turbo:submit-end' event handler.
*
* @param {jQuery.Event} event
* The event object generated by Turbo.
*/
#formSubmitEndHandler(event) {
const refreshlessEvent = new CustomEvent(
'refreshless:form-submit-response', {detail: event.detail},
);
this.#context.dispatchEvent(refreshlessEvent);
}
/**
* 'turbo:render' event handler.
*
* @param {jQuery.Event} event
* The event object generated by Turbo.
*
* @see this.#renderTasks()
*/
async #renderHandler(event) {
// If this render happens right after a redirect, skip the next render.
if (this.#isRedirected === true) {
this.#isRedirected = false;
this.#skipRender = true;
return;
}
// Ensure an exception being thrown in the render tasks does not break
// navigation but is still logged as an error.
try {
await this.#renderTasks(event);
} catch (error) {
console.error(error);
}
}
/**
* 'turbo:render' event tasks.
*
* @param {jQuery.Event} event
* The event object generated by Turbo.
*/
async #renderTasks(event) {
const renderEvent = new CustomEvent(
'refreshless:render', {detail: {
isCachedSnapshot: this.#isCachedSnapshot,
isFreshReplacingPreview: this.#isFreshReplacingPreview,
isPreview: this.#isPreview,
previousPreview: this.#previousPreview,
renderMethod: event.detail.renderMethod,
}},
);
this.#context.dispatchEvent(renderEvent);
// Don't update drupalSettings and don't attach behaviours if this is a
// preview; only do that on the fresh version from a server response.
if (this.#isPreview === true) {
return;
}
// When Turbo renders a cached snapshot that isn't a preview - i.e.
// usually the result of a restore navigation via browser history - a lot
// of behaviours will not attach because the page already has elements
// marked by once() attributes as that's how they were cached. Since we
// want previews to be visually similar or identical to the final content
// that replaces them, we have to detach from the now rendered snapshot
// here to attempt to undo changes for the attach callbacks to apply
// again.
if (this.#isCachedSnapshot === true) {
await this.#Behaviours.detach(true, 'refreshless:cached-snapshot');
}
// Catch any exception thrown by TurboDrupalSettings and log it so that
// execution continues and behaviours can be attached. While this may
// lead to some broken things, it's likely a lot less broken than without
// behaviours.
try {
await this.#DrupalSettings.update();
} catch (error) {
console.error(error);
}
await this.#Behaviours.attach();
}
/**
* 'turbo:before-fetch-request' event handler.
*
* @param {jQuery.Event} event
* The event object generated by Turbo.
*/
#beforeFetchRequestHandler(event) {
// Add a header indicating that this is a RefreshLess request.
event.detail.fetchOptions.headers[
drupalSettings.refreshless.headerName
] = 'true';
/**
* Whether the current request is a prefetch request.
*
* @type {Boolean}
*/
let isPrefetch = false;
if (
'X-Sec-Purpose' in event.detail.fetchOptions.headers &&
event.detail.fetchOptions.headers['X-Sec-Purpose'] === 'prefetch'
) {
isPrefetch = true;
}
/**
* Whether the current request is a preload request.
*
* @type {Boolean}
*/
let isPreload = false;
if (
'X-Sec-Purpose' in event.detail.fetchOptions.headers &&
event.detail.fetchOptions.headers['X-Sec-Purpose'] === 'preload'
) {
isPreload = true;
}
/**
* Whether the current request is a form submit request.
*
* Note that we're not checking if this is a POST request because GET
* requests are also made in some cases, notably Views exposed forms when
* clicking the Reset button.
*
* @type {Boolean}
*/
let isFormSubmit = $(event.target).is('form');
/**
* The request URL object.
*
* @type {URL}
*/
const requestUrl = event.detail.url;
event.detail.refreshless = {
isPrefetch,
isPreload,
isFormSubmit,
requestUrl,
};
const beforeFetchEvent = new CustomEvent(
'refreshless:before-fetch-request', {
cancelable: true,
detail: {
fetchOptions: event.detail.fetchOptions,
isFormSubmit: isFormSubmit,
isPrefetch: isPrefetch,
isPreload: isPreload,
resume: event.detail.resume,
url: requestUrl,
},
},
);
this.#context.dispatchEvent(beforeFetchEvent);
if (beforeFetchEvent.defaultPrevented === true) {
event.preventDefault();
}
}
/**
* 'turbo:before-fetch-response' event handler.
*
* @param {jQuery.Event} event
* The event object generated by Turbo.
*/
#beforeFetchResponseHandler(event) {
// Turbo does not perform a double render when submitting a form, so
// return on that redirect as that would otherwise cause the destination
// to not have behaviours attached.
if (event.detail.refreshless.isFormSubmit === true) {
return;
}
/**
* The response URL object.
*
* @type {URL}
*/
const responseUrl = new URL(
event.detail.fetchResponse.response.url,
);
// If this fetch request was made to prefetch, trigger the event and
// return.
if (event.detail.refreshless.isPrefetch === true) {
this.#triggerPrefetch(event, responseUrl);
return;
}
// If this fetch request was made to preload, trigger the event and
// return.
if (event.detail.refreshless.isPreload === true) {
this.#triggerPreload(event, responseUrl);
return;
}
// If this was a redirect, trigger the redirect event and return.
if (event.detail.fetchResponse.response.redirected === true) {
this.#triggerRedirect(
event, event.detail.refreshless.requestUrl, responseUrl,
);
return;
}
// If none of the above, trigger the navigation event.
this.#triggerNavigation(event, responseUrl);
}
/**
* 'turbo:fetch-request-error' event handler.
*
* @param {jQuery.Event} event
* The event object generated by Turbo.
*/
#fetchRequestErrorHandler(event) {
const fetchErrorEvent = new CustomEvent(
'refreshless:fetch-request-error', {
detail: {
fetchRequest: event.detail.request,
error: event.detail.error,
},
},
);
this.#context.dispatchEvent(fetchErrorEvent);
}
/**
* Trigger the 'refreshless:navigation-response' event.
*
* @param {jQuery.Event} turboEvent
* The event object generated by Turbo.
*
* @param {URL} url
* The URL that the response is navigating to.
*/
#triggerNavigation(turboEvent, url) {
const navigationResponseEvent = new CustomEvent(
'refreshless:navigation-response', {
detail: {
fetchResponse: turboEvent.detail.fetchResponse,
url: url,
},
}
);
this.#context.dispatchEvent(navigationResponseEvent);
}
/**
* Trigger the 'refreshless:prefetch' event.
*
* @param {jQuery.Event} turboEvent
* The event object generated by Turbo.
*
* @param {URL} url
* The URL that was prefetched.
*/
#triggerPrefetch(turboEvent, url) {
const prefetchEvent = new CustomEvent(
'refreshless:prefetch', {
detail: {
fetchResponse: turboEvent.detail.fetchResponse,
url: url,
},
},
);
this.#context.dispatchEvent(prefetchEvent);
}
/**
* Trigger the 'refreshless:preload' event.
*
* @param {jQuery.Event} turboEvent
* The event object generated by Turbo.
*
* @param {URL} url
* The URL that was preloaded.
*/
#triggerPreload(turboEvent, url) {
const prefetchEvent = new CustomEvent(
'refreshless:preload', {
detail: {
fetchResponse: turboEvent.detail.fetchResponse,
url: url,
},
},
);
this.#context.dispatchEvent(prefetchEvent);
}
/**
* Trigger the 'refreshless:redirect' event.
*
* @param {jQuery.Event} turboEvent
* The event object generated by Turbo.
*
* @param {URL} fromUrl
* The originally requested URL.
*
* @param {URL} toUrl
* The URL redirected to.
*/
#triggerRedirect(turboEvent, fromUrl, toUrl) {
const redirectEvent = new CustomEvent(
'refreshless:redirect', {
detail: {
fetchResponse: turboEvent.detail.fetchResponse,
from: fromUrl,
to: toUrl,
},
},
);
this.#context.dispatchEvent(redirectEvent);
this.#isRedirected = true;
}
}
// Don't accidentally initialize more than once. This shouldn't happen, but
// there are still edge cases in our additive aggregation.
if ('Turbo' in Drupal.RefreshLess) {
return;
}
// Merge Drupal.RefreshLess.classes into the existing Drupal global.
$.extend(true, Drupal, {RefreshLess: {classes: {Turbo: RefreshLessTurbo}}});
Drupal.RefreshLess.Turbo = new RefreshLessTurbo(
html, TurboBehaviours, TurboDrupalSettings, TurboProgressBar,
TurboScriptManager, TurboStylesheetManager, Turbo,
);
// Provides a shorter alias for our visit method.
//
// @todo Create a way for implementations to provide a set of expected methods
// on Drupal.RefreshLess; ideally will involve TypeScript interfaces when we
// switch over.
Drupal.RefreshLess.visit = (location, options) => {
Drupal.RefreshLess.Turbo.visit(location, options);
};
})(
document.documentElement,
Drupal,
Drupal.RefreshLess.classes.TurboBehaviours,
Drupal.RefreshLess.classes.TurboDrupalSettings,
Drupal.RefreshLess.classes.TurboProgressBar,
Drupal.RefreshLess.classes.TurboScriptManager,
Drupal.RefreshLess.classes.TurboStylesheetManager,
drupalSettings,
jQuery,
Turbo,
);
