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);

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc