refreshless-8.x-1.x-dev/components/lazy-link-preloader/lazy-link-preloader.js
components/lazy-link-preloader/lazy-link-preloader.js
/**
* @file
* Lazy link preloader.
*
* This creates an IntersectionObserver that automatically instructs Turbo to
* preload links into its cache. This is an opt-in feature that requires adding
* a 'data-refreshless-lazy-preload' attribute to part of a page to watch.
*
* @todo Remove Turbo dependency and provide a thin wrapper to invoke and query
* the cache so we can swap that out for htmx in the future.
*/
(function(html, Drupal, $, Turbo) {
'use strict';
// @todo Remove when SDC additive aggregation is fixed.
//
// @see https://www.drupal.org/project/refreshless/issues/3543409
if (Drupal?.RefreshLess?.preloader) {
return;
}
/**
* Our event namespace.
*
* @type {String}
*
* @see https://learn.jquery.com/events/event-basics/#namespacing-events
*/
const eventNamespace = 'refreshless-lazy-link-preloader';
/**
* Link wrapper to normalize link URLs.
*/
class LinkWrapper {
/**
* The link element this wraps.
*
* @type {HTMLAnchorElement}
*/
#link;
/**
* The constructed URL object for this link.
*
* @type {URL}
*/
#url;
constructor(link) {
this.#link = link;
this.#url = new URL(link.href);
// Remove any hash as it's irrelevant because we only care about the page
// itself, not any in-page anchors. This could also lead to duplicate
// requestst if more than one in-page anchor on a page is linked to.
this.#url.hash = '';
}
get link() {
return this.#link;
}
get url() {
return this.#url;
}
}
/**
* Represents a link preload.
*/
class LinkPreload {
/**
* The link element this wraps.
*
* @type {HTMLAnchorElement}
*/
#link;
/**
* Whether this preload has loaded.
*
* @type {Boolean}
*/
#loaded = false;
/**
* Preload function to call to preload this link.
*
* @type {Function}
*/
#preloader;
/**
* A Promise that fulfills when the preload has finished.
*
* @type {Promise}
*/
#promise;
/**
* Whether this preload has started.
*
* @type {Boolean}
*/
#started = false;
constructor(link, preloader) {
this.#link = link;
this.#preloader = preloader;
}
/**
* Start preloading this link.
*
* @return {Promise}
* A Promise that fulfills when the preload has finished.
*/
start() {
if (this.#started === true) {
return this.#promise;
}
this.#started = true;
this.#promise = this.#preloader(this.#link);
this.#promise.then(() => { this.#loaded = true; }, () => {});
return this.#promise;
}
get link() {
return this.#link;
}
get loaded() {
return this.#loaded;
}
get promise() {
return this.#promise;
}
get started() {
return this.#started;
}
}
/**
* RefreshLess Turbo lazy link preloader class.
*/
class LazyLinkPreloader {
/**
* The context element to attach to; usually the <html> element.
*
* @type {HTMLElement}
*/
#context;
/**
* An IntersectionObserver instance.
*
* @type {IntersectionObserver}
*/
#observer;
/**
* Hotwire Turbo instance.
*
* @type {Turbo}
*/
#Turbo;
/**
* The selector to find links by.
*
* This is further filtered using the data attribute but serves as a
* starting point.
*
* @type {String}
*/
linkSelector = 'a[href]';
/**
* The preload data attribute.
*
* This functions similarly to 'data-turbo' where the presence of it opts
* in, but if the value is set to 'false', that will opt out a descendent if
* an ancestor has opted in.
*
* @type {String}
*
* @see https://turbo.hotwired.dev/reference/attributes
*/
preloadAttributeName = 'data-refreshless-lazy-preload';
/**
* The maximum number of preloads to run in parallel.
*
* @type {Number}
*/
#maxParallel = 4;
/**
* The queue of currently running preloads.
*
* @type {LinkPreload[]}
*/
#activeQueue = [];
/**
* The queue of preloads waiting to be started.
*
* @type {LinkPreload[]}
*/
#waitingQueue = [];
/**
* Array of URLs that have been processed.
*
* This is used to quickly look up if a single URL is currently queued or
* running to ensure only a single request is sent. URLs are removed from
* this when their request has been received and cached, after which Turbo's
* cache is queried to tell us if the URL is still cached.
*
* @type {String[]}
*
* @see this.shouldPreloadLink()
* Contains the checks for whether a URL is in this array and if it's in
* Turbo's cache.
*/
#processedUrls = [];
/**
* Whether the preloader is currently running.
*
* @type {Boolean}
*/
#running = false;
constructor(context, Turbo) {
this.#context = context;
this.#Turbo = Turbo;
this.#observer = new IntersectionObserver((entries, observer) => {
this.#observerCallback(entries, observer);
});
this.#bindEventHandlers();
}
/**
* Destroy this instance.
*/
destroy() {
this.#unbindEventHandlers();
}
/**
* Bind all of our event handlers.
*/
#bindEventHandlers() {
// @see https://ambientimpact.com/web/snippets/javascript-template-literal-as-object-property-name
$(this.#context).on({
[`refreshless:load.${eventNamespace}`]: (event) => {
this.start();
},
[`refreshless:detach.${eventNamespace}`]: (event) => {
this.stop();
},
[`refreshless:before-fetch-request.${eventNamespace}`]: (event) => {
this.#beforeFetchRequestHandler(event);
},
[`refreshless:before-prefetch.${eventNamespace}`]: (event) => {
this.#beforePrefetchHandler(event);
},
});
}
/**
* Unbind all of our event handlers.
*/
#unbindEventHandlers() {
$(this.#context).off(`.${eventNamespace}`);
}
/**
* IntersectionObserver callback.
*
* @param {IntersectionObserverEntry[]} entries
* Array of entries.
*
* @param {IntersectionObserver} observer
* The observer instance.
*/
#observerCallback(entries, observer) {
const preloadableLinks = [];
entries.forEach((entry) => {
if (
!this.shouldPreloadLink(entry.target) ||
!entry.isIntersecting
) {
return;
}
preloadableLinks.push(entry.target);
});
$(preloadableLinks).each((i, element) => {
this.preloadLink(element);
});
}
/**
* 'refreshless:before-fetch-request' event handler.
*
* @param {jQuery.Event} event
* The event object.
*/
#beforeFetchRequestHandler(event) {
if (
event.detail.isPreload === false ||
'priority' in event.detail.fetchOptions
) {
return;
}
event.preventDefault();
// Set all preload requests to low priority so they're (hopefully) less
// likely to block actual navigation requests.
//
// @todo Can we do this just to the requests we're sending here?
// Turbo.session.preloader.preloadURL() doesn't allow setting options
// when calling it.
event.detail.fetchOptions.priority = 'low';
event.detail.resume();
}
/**
* 'refreshless:before-prefetch' event handler.
*
* @param {jQuery.Event} event
* The event object.
*/
#beforePrefetchHandler(event) {
// Don't prefetch URLs that we've preloaded. While not strictly necessary,
// this reduces network requests that are likely to identical to what's
// preloaded.
if (this.shouldPreloadLink(event.target) === false) {
event.preventDefault();
}
}
/**
* Whether a link is eligible for preloading.
*
* @param {HTMLAnchorElement} link
* A link element to check.
*
* @return {Boolean}
* True if the link is eligible for preloading, and false otherwise.
*/
shouldPreloadLink(link) {
const linkWrapper = new LinkWrapper(link);
const inCache = this.#Turbo.session.preloader.snapshotCache.has(
linkWrapper.url,
);
// Don't process a URL if it's already in Turbo's cache or it's currently
// in progress.
if (
inCache === true ||
this.#processedUrls.indexOf(linkWrapper.url.href) > -1
) {
return false;
}
const locationUrl = new URL(location.href);
locationUrl.hash = '';
// Turbo.session.shouldPreloadLink() currently returns true for links that
// point to an in-page anchor on the same location we're currently on, so
// we must detect that here and indicate that it's not preloadable.
if (locationUrl.href === linkWrapper.url.href) {
return false;
}
return this.#Turbo.session.shouldPreloadLink(link);
}
/**
* Preload a provided link.
*
* @param {HTMLAnchorElement} link
* A link element to preload.
*
* @return {Promise}
* A Promise that resolves when the fetch request has succeeded or failed.
*/
async preloadLink(link) {
if (this.shouldPreloadLink(link) === false) {
return Promise.resolve();
}
const linkWrapper = new LinkWrapper(link);
this.#processedUrls.push(linkWrapper.url.href);
const preload = this.#createPreload(link);
this.#waitingQueue.push(preload);
return await this.#startNextPreload();
}
/**
* Create a preload for a given link element.
*
* @param {HTMLAnchorElement} link
* A link element.
*
* @return {LinkPreload}
* A LinkPreload instance.
*/
#createPreload(link) {
const preload = new LinkPreload(
link,
(link) => this.#Turbo.session.preloader.preloadURL(link),
);
return preload;
}
/**
* Attempt to adjust the #maxParallel value based on detected network speed.
*/
#adjustMaxParallel() {
// Reduce the #maxParallel value if we detect slower networks.
if (
navigator.connection?.effectiveType === 'slow-2g' ||
navigator.connection?.effectiveType === '2g'
) {
this.#maxParallel = 1;
} else if (navigator.connection?.effectiveType === '3g') {
this.#maxParallel = 2;
// Default value.
} else {
this.#maxParallel = 4;
}
}
/**
* Start the next item in the waiting queue if possible.
*
* @return {Promise}
* A Promise that fulfills or rejects based on criteria:
*
* - If there is no preload waiting and the queue is empty, returns a
* rejected Promise.
*
* - If there is no preload waiting but one or more preloads may be in
* progress, returns a rejected Promise.
*
* - If the preload queue is currently running and full, returns a Promise
* that fulfills when one of the currently running preloads finishes.
*
* - If there's a preload waiting and the running queue isn't full,
* starts that preload and returns a Promise that fulfills when that
* preload completes.
*
* A fulfilled Promise indicates that further calls of this method are
* required to continue the queue, and a rejected Promise indicates that
* no more calls to this method are necessary, i.e. all preloads are
* running or finished.
*/
async #startNextPreload() {
this.#adjustMaxParallel();
// If both active and waiting queues are empty, return a rejected Promise
// indicating queue processing is done.
if (
this.#activeQueue.length === 0 &&
this.#waitingQueue.length === 0
) {
return Promise.reject();
}
// If we've hit the maximum parallel preloads, return a Promise that
// settles when one of the active preloads finishes.
if (this.#activeQueue.length >= this.#maxParallel) {
return Promise.race(
this.#activeQueue.map((preload) => preload.promise),
);
}
// If everything is in the active queue and nothing waiting, return a
// rejected Promise here indicating queue processing is done.
if (this.#waitingQueue.length === 0) {
return Promise.reject();
}
const preload = this.#waitingQueue.shift();
this.#activeQueue.push(preload);
return await preload.start().then(async () => {
// Remove the URL from the processed array once it's loaded so that we
// can preload it again if Turbo no longer has it in its cache.
this.#processedUrls = this.#processedUrls.filter((url) => {
const linkWrapper = new LinkWrapper(preload.link);
return url !== linkWrapper.url.href;
});
const index = this.#activeQueue.indexOf(preload);
// If this link isn't in the queue at the time that we check this, that
// likely means the queue has been cleared due to the user navigating
// to another page before this link had loaded, so don't do anything.
if (index === -1) {
return;
}
this.#activeQueue.splice(index, 1);
return await this.#startNextPreload();
// @todo What should we do if the request fails?
}).catch(() => {});
}
/**
* Start observing links within the context element.
*/
start() {
if (this.#running === true) {
return;
}
// Don't do anything if data saving is detected as enabled.
if (navigator.connection?.saveData === true) {
return;
}
this.#running = true;
this.#activeQueue = [];
this.#waitingQueue = [];
this.#processedUrls = [];
// Avoid doing anything if no part of the page has opted in.
if ($(this.#context).find(`[${
this.preloadAttributeName
}]`).length === 0) {
return;
}
/**
* The top level roots that have the attribute, regardless of value.
*
* Note that if we were to filter the top level by their value, this
* would miss descendents that opt back in.
*
* @type {jQuery}
*/
const $roots = $(this.#context).find(`[${
this.preloadAttributeName
}]`).filter((i, element) => {
return $(element).parents(`[${
this.preloadAttributeName
}]`).length === 0;
});
/**
* All links within the root elements that are opted in to preloading.
*
* This checks the closest element up the tree with the attribute and
* ignores any links whose closest element has opted out.
*
* @type {jQuery}
*/
const $links = $roots.find(this.linkSelector).add($roots.filter(
// Also include links that are themselves roots.
this.linkSelector,
)).filter((i, element) => {
return $(element).closest(`[${this.preloadAttributeName}]`).not(`[${
this.preloadAttributeName
}=false]`).length > 0;
}).filter((i, element) => {
return this.shouldPreloadLink(element);
});
$links.each((i, element) => {
this.#observer.observe(element);
});
}
/**
* Stop observing links within the context element.
*/
stop() {
if (this.#running === false) {
return;
}
this.#running = false;
this.#observer.disconnect();
}
}
// Merge both the class and an instance into the existing Drupal global.
$.extend(true, Drupal, {RefreshLess: {
classes: {
LazyLinkPreloader: LazyLinkPreloader,
},
preloader: new LazyLinkPreloader(html, Turbo),
}});
})(document.documentElement, Drupal, jQuery, Turbo);
