Unverified Commit 6da66e99 authored by Lauri Timmanee's avatar Lauri Timmanee
Browse files

Issue #3196973 by casey, nod_, andypost, yogeshmpawar, droplet, Wim Leers,...

Issue #3196973 by casey, nod_, andypost, yogeshmpawar, droplet, Wim Leers, justafish, finnsky: Use Mutation observer for BigPipe replacements
parent 5496d64a
Loading
Loading
Loading
Loading
+0 −1
Original line number Diff line number Diff line
@@ -5,6 +5,5 @@ big_pipe:
  drupalSettings:
    bigPipePlaceholderIds: []
  dependencies:
    - core/once
    - core/drupal.ajax
    - core/drupalSettings
+89 −88
Original line number Diff line number Diff line
@@ -3,15 +3,39 @@
 * Renders BigPipe placeholders using Drupal's Ajax system.
 */

(function (Drupal, drupalSettings) {
((Drupal, drupalSettings) => {
  /**
   * Maps textContent of <script type="application/vnd.drupal-ajax"> to an AJAX response.
   * CSS selector for script elements to process on page load.
   *
   * @type {string}
   */
  const replacementsSelector = `script[data-big-pipe-replacement-for-placeholder-with-id]`;

  /**
   * Ajax object that will process all the BigPipe responses.
   *
   * Create a Drupal.Ajax object without associating an element, a progress
   * indicator or a URL.
   *
   * @type {Drupal.Ajax}
   */
  const ajaxObject = Drupal.ajax({
    url: '',
    base: false,
    element: false,
    progress: false,
  });

  /**
   * Maps textContent of <script type="application/vnd.drupal-ajax"> to an AJAX
   * response.
   *
   * @param {string} content
   *   The text content of a <script type="application/vnd.drupal-ajax"> DOM node.
   *   The text content of a <script type="application/vnd.drupal-ajax"> DOM
   *   node.
   * @return {Array|boolean}
   *   The parsed Ajax response containing an array of Ajax commands, or false in
   *   case the DOM node hasn't fully arrived yet.
   *   The parsed Ajax response containing an array of Ajax commands, or false
   *   in case the DOM node hasn't fully arrived yet.
   */
  function mapTextContentToAjaxResponse(content) {
    if (content === '') {
@@ -30,108 +54,85 @@
   *
   * These Ajax commands replace placeholders with HTML and load missing CSS/JS.
   *
   * @param {HTMLScriptElement} placeholderReplacement
   * @param {HTMLScriptElement} replacement
   *   Script tag created by BigPipe.
   */
  function bigPipeProcessPlaceholderReplacement(placeholderReplacement) {
    const placeholderId = placeholderReplacement.getAttribute(
      'data-big-pipe-replacement-for-placeholder-with-id',
    );
    const content = placeholderReplacement.textContent.trim();
  function processReplacement(replacement) {
    const id = replacement.dataset.bigPipeReplacementForPlaceholderWithId;
    // Because we use a mutation observer the content is guaranteed to be
    // complete at this point.
    const content = replacement.textContent.trim();

    // Ignore any placeholders that are not in the known placeholder list. Used
    // to avoid someone trying to XSS the site via the placeholdering mechanism.
    if (
      typeof drupalSettings.bigPipePlaceholderIds[placeholderId] !== 'undefined'
    ) {
    if (typeof drupalSettings.bigPipePlaceholderIds[id] === 'undefined') {
      return;
    }

    // Immediately remove the replacement to prevent it being processed twice.
    delete drupalSettings.bigPipePlaceholderIds[id];

    const response = mapTextContentToAjaxResponse(content);
      // If we try to parse the content too early (when the JSON containing Ajax
      // commands is still arriving), textContent will be empty or incomplete.

    if (response === false) {
        /**
         * Mark as unprocessed so this will be retried later.
         * @see bigPipeProcessDocument()
         */
        once.remove('big-pipe', placeholderReplacement);
      } else {
        // Create a Drupal.Ajax object without associating an element, a
        // progress indicator or a URL.
        const ajaxObject = Drupal.ajax({
          url: '',
          base: false,
          element: false,
          progress: false,
        });
        // Then, simulate an AJAX response having arrived, and let the Ajax
        // system handle it.
        ajaxObject.success(response, 'success');
      }
    }
      return;
    }

  // The frequency with which to check for newly arrived BigPipe placeholders.
  // Hence 50 ms means we check 20 times per second. Setting this to 100 ms or
  // more would cause the user to see content appear noticeably slower.
  const interval = drupalSettings.bigPipeInterval || 50;

  // The internal ID to contain the watcher service.
  let timeoutID;
    // Then, simulate an AJAX response having arrived, and let the Ajax system
    // handle it.
    ajaxObject.success(response, 'success');
  }

  /**
   * Processes a streamed HTML document receiving placeholder replacements.
   * Check that the element is valid to process and process it.
   *
   * @param {HTMLDocument} context
   *   The HTML document containing <script type="application/vnd.drupal-ajax">
   *   tags generated by BigPipe.
   *
   * @return {bool}
   *   Returns true when processing has been finished and a stop signal has been
   *   found.
   * @param {HTMLElement} node
   *  The node added to the body element.
   */
  function bigPipeProcessDocument(context) {
    // Make sure we have BigPipe-related scripts before processing further.
    if (!context.querySelector('script[data-big-pipe-event="start"]')) {
      return false;
  function checkMutationAndProcess(node) {
    if (
      node.nodeType === Node.ELEMENT_NODE &&
      node.nodeName === 'SCRIPT' &&
      node.dataset &&
      node.dataset.bigPipeReplacementForPlaceholderWithId
    ) {
      processReplacement(node);
    }
  }

    // Attach Drupal behaviors early, if possible.
    once('big-pipe-early-behaviors', 'body', context).forEach((el) => {
      Drupal.attachBehaviors(el);
  /**
   * Handles the mutation callback.
   *
   * @param {MutationRecord[]} mutations
   *  The list of mutations registered by the browser.
   */
  function processMutations(mutations) {
    mutations.forEach(({ addedNodes }) => {
      addedNodes.forEach(checkMutationAndProcess);
    });

    once(
      'big-pipe',
      'script[data-big-pipe-replacement-for-placeholder-with-id]',
      context,
    ).forEach(bigPipeProcessPlaceholderReplacement);

    // If we see the stop signal, clear the timeout: all placeholder
    // replacements are guaranteed to be received and processed.
    if (context.querySelector('script[data-big-pipe-event="stop"]')) {
      if (timeoutID) {
        clearTimeout(timeoutID);
      }
      return true;
  }

    return false;
  }
  const observer = new MutationObserver(processMutations);

  function bigPipeProcess() {
    timeoutID = setTimeout(() => {
      if (!bigPipeProcessDocument(document)) {
        bigPipeProcess();
      }
    }, interval);
  }
  // Attach behaviors early, if possible.
  Drupal.attachBehaviors(document.body);

  // If loaded asynchronously there might already be replacement elements
  // in the DOM before the mutation observer is started.
  document.querySelectorAll(replacementsSelector).forEach(processReplacement);

  bigPipeProcess();
  // Start observing the body element for new children.
  observer.observe(document.body, { childList: true });

  // If something goes wrong, make sure everything is cleaned up and has had a
  // chance to be processed with everything loaded.
  window.addEventListener('load', () => {
    if (timeoutID) {
      clearTimeout(timeoutID);
  // As soon as the document is loaded, no more replacements will be added.
  // Immediately fetch and process all pending mutations and stop the observer.
  window.addEventListener('DOMContentLoaded', () => {
    const mutations = observer.takeRecords();
    observer.disconnect();
    if (mutations.length) {
      processMutations(mutations);
    }
    bigPipeProcessDocument(document);
    // No more mutations will be processed, remove the leftover Ajax object.
    Drupal.ajax.instances[ajaxObject.instanceIndex] = null;
  });
})(Drupal, drupalSettings);
+60 −58
Original line number Diff line number Diff line
@@ -530,8 +530,11 @@
   */
  Drupal.behaviors.ckeditor5Admin = {
    attach(context) {
      once('ckeditor5-admin-toolbar', '#ckeditor5-toolbar-app').forEach(
        (container) => {
      once(
        'ckeditor5-admin-toolbar',
        '#ckeditor5-toolbar-app',
        context,
      ).forEach((container) => {
        const selectedTextarea = context.querySelector(
          '#ckeditor5-toolbar-buttons-selected',
        );
@@ -591,8 +594,7 @@
          });

        render(container, selected, available, dividers);
        },
      );
      });
      // Safari's focus outlines take into account absolute positioned elements.
      // When a toolbar option is blurred, the portion of the focus outline
      // surrounding the absolutely positioned tooltip does not go away. To
+2 −1
Original line number Diff line number Diff line
@@ -54,7 +54,8 @@ protected function assertBigPipePlaceholderReplacementCount($expected_count): vo
    $web_assert = $this->assertSession();
    $web_assert->waitForElement('css', 'script[data-big-pipe-event="stop"]');
    $page = $this->getSession()->getPage();
    $this->assertCount($expected_count, $this->getDrupalSettings()['bigPipePlaceholderIds']);
    // Settings are removed as soon as they are processed.
    $this->assertCount(0, $this->getDrupalSettings()['bigPipePlaceholderIds']);
    $this->assertCount($expected_count, $page->findAll('css', 'script[data-big-pipe-replacement-for-placeholder-with-id]'));
  }