Commit f976d5d4 authored by Ben Mullins's avatar Ben Mullins
Browse files

Issue #1988968 by nod_, droplet, bnjmnm, viappidu, extect, jansete, olli,...

Issue #1988968 by nod_, droplet, bnjmnm, viappidu, extect, jansete, olli, martin107, pmagunia, bartlangelaan, Wim Leers, zrpnr, KapilV, yogeshmpawar, Spleshka, Phil Wolstenholme, DuaelFr, agata.guc, alwaysworking, dawid_nawrot, andriic, keithdoyle9, gapple, lauriii, Martijn de Wit, jefuri, JMOmandown, larowlan, rubens.arjr, borisson_, joseph.olstad, jberube, gilgabar, Poindexterous, Aless86, jessebeach, bojanz, phma, aheimlich, heddn, phenaproxima, acbramley, codebymikey, cmlara, das-peter, matthiasm11, acolden, xjm, jrockowitz, pianomansam, clairemistry, John Pitcairn: Drupal.ajax does not guarantee that "add new JS file to page" commands have finished before calling said JS
parent 02defec9
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
@@ -370,6 +370,7 @@ drupal.ajax:
    - core/drupal.progress
    - core/once
    - core/tabbable
    - core/loadjs

drupal.announce:
  version: VERSION
+61 −0
Original line number Diff line number Diff line
<?php

namespace Drupal\Core\Ajax;

/**
 * An AJAX command for adding JS to the page via AJAX.
 *
 * This command will make sure all the files are loaded before continuing
 * executing the next AJAX command. This command is implemented by
 * Drupal.AjaxCommands.prototype.add_js() defined in misc/ajax.js.
 *
 * @see misc/ajax.js
 *
 * @ingroup ajax
 */
class AddJsCommand implements CommandInterface {

  /**
   * An array containing attributes of the scripts to be added to the page.
   *
   * @var string[]
   */
  protected $scripts;

  /**
   * A CSS selector string.
   *
   * If the command is a response to a request from an #ajax form element then
   * this value will default to 'body'.
   *
   * @var string
   */
  protected $selector;

  /**
   * Constructs an AddJsCommand.
   *
   * @param array $scripts
   *   An array containing the attributes of the 'script' tags to be added to
   *   the page. i.e. `['src' => 'someURL', 'defer' => TRUE]` becomes
   *   `<script src="someURL" defer>`.
   * @param string $selector
   *   A CSS selector of the element where the script tags will be appended.
   */
  public function __construct(array $scripts, string $selector = 'body') {
    $this->scripts = $scripts;
    $this->selector = $selector;
  }

  /**
   * {@inheritdoc}
   */
  public function render() {
    return [
      'command' => 'add_js',
      'selector' => $this->selector,
      'data' => $this->scripts,
    ];
  }

}
+2 −2
Original line number Diff line number Diff line
@@ -174,11 +174,11 @@ protected function buildAttachmentsCommands(AjaxResponse $response, Request $req
    }
    if ($js_assets_header) {
      $js_header_render_array = $this->jsCollectionRenderer->render($js_assets_header);
      $resource_commands[] = new PrependCommand('head', $this->renderer->renderPlain($js_header_render_array));
      $resource_commands[] = new AddJsCommand(array_column($js_header_render_array, '#attributes'), 'head');
    }
    if ($js_assets_footer) {
      $js_footer_render_array = $this->jsCollectionRenderer->render($js_assets_footer);
      $resource_commands[] = new AppendCommand('body', $this->renderer->renderPlain($js_footer_render_array));
      $resource_commands[] = new AddJsCommand(array_column($js_footer_render_array, '#attributes'));
    }
    foreach (array_reverse($resource_commands) as $resource_command) {
      $response->addCommand($resource_command, TRUE);
+177 −51
Original line number Diff line number Diff line
@@ -11,7 +11,14 @@
 * included to provide Ajax capabilities.
 */

(function ($, window, Drupal, drupalSettings, { isFocusable, tabbable }) {
(function (
  $,
  window,
  Drupal,
  drupalSettings,
  loadjs,
  { isFocusable, tabbable },
) {
  /**
   * Attaches the Ajax behavior to each Ajax form element.
   *
@@ -537,10 +544,23 @@
          }
        }

        return ajax.success(response, status);
        return (
          // Ensure that the return of the success callback is a Promise.
          // When the return is a Promise, using resolve will unwrap it, and
          // when the return is not a Promise we make sure it can be used as
          // one. This is useful for code that overrides the success method.
          Promise.resolve(ajax.success(response, status))
            // Ajaxing status is back to false when all the AJAX commands have
            // finished executing.
            .then(() => {
              ajax.ajaxing = false;
            })
        );
      },
      complete(xmlhttprequest, status) {
      error(xmlhttprequest, status, error) {
        ajax.ajaxing = false;
      },
      complete(xmlhttprequest, status) {
        if (status === 'error' || status === 'parsererror') {
          return ajax.error(xmlhttprequest, ajax.url);
        }
@@ -950,6 +970,36 @@
    $('body').append(this.progress.element);
  };

  /**
   * Helper method to make sure commands are executed in sequence.
   *
   * @param {Array.<Drupal.AjaxCommands~commandDefinition>} response
   *   Drupal Ajax response.
   * @param {number} status
   *   XMLHttpRequest status.
   *
   * @return {Promise}
   *  The promise that will resolve once all commands have finished executing.
   */
  Drupal.Ajax.prototype.commandExecutionQueue = function (response, status) {
    const ajaxCommands = this.commands;
    return Object.keys(response || {}).reduce(
      // Add all commands to a single execution queue.
      (executionQueue, key) =>
        executionQueue.then(() => {
          const { command } = response[key];
          if (command && ajaxCommands[command]) {
            // When a command returns a promise, the remaining commands will not
            // execute until that promise has been fulfilled. This is typically
            // used to ensure JavaScript files added via the 'add_js' command
            // have loaded before subsequent commands execute.
            return ajaxCommands[command](this, response[key], status);
          }
        }),
      Promise.resolve(),
    );
  };

  /**
   * Handler for the form redirection completion.
   *
@@ -957,6 +1007,9 @@
   *   Drupal Ajax response.
   * @param {number} status
   *   XMLHttpRequest status.
   *
   * @return {Promise}
   * The promise that will resolve once all commands have finished executing.
   */
  Drupal.Ajax.prototype.success = function (response, status) {
    // Remove the progress element.
@@ -979,23 +1032,19 @@

    // Track if any command is altering the focus so we can avoid changing the
    // focus set by the Ajax command.
    let focusChanged = false;
    Object.keys(response || {}).forEach((i) => {
      if (response[i].command && this.commands[response[i].command]) {
        this.commands[response[i].command](this, response[i], status);
        if (
          (response[i].command === 'invoke' &&
            response[i].method === 'focus') ||
          response[i].command === 'focusFirst'
        ) {
          focusChanged = true;
        }
      }
    const focusChanged = Object.keys(response || {}).some((key) => {
      const { command, method } = response[key];
      return (
        command === 'focusFirst' || (command === 'invoke' && method === 'focus')
      );
    });

    // If the focus hasn't be changed by the ajax commands, try to refocus the
    // triggering element or one of its parents if that element does not exist
    // anymore.
    return (
      this.commandExecutionQueue(response, status)
        // If the focus hasn't been changed by the AJAX commands, try to refocus
        // the triggering element or one of its parents if that element does not
        // exist anymore.
        .then(() => {
          if (
            !focusChanged &&
            this.element &&
@@ -1010,24 +1059,34 @@
                )}"]`,
              );
            }

            if (target) {
              $(target).trigger('focus');
            }
          }

          // Reattach behaviors, if they were detached in beforeSerialize(). The
    // attachBehaviors() called on the new content from processing the response
    // commands is not sufficient, because behaviors from the entire form need
    // to be reattached.
          // attachBehaviors() called on the new content from processing the
          // response commands is not sufficient, because behaviors from the
          // entire form need to be reattached.
          if (this.$form && document.body.contains(this.$form.get(0))) {
            const settings = this.settings || drupalSettings;
            Drupal.attachBehaviors(this.$form.get(0), settings);
          }

    // Remove any response-specific settings so they don't get used on the next
    // call by mistake.
          // Remove any response-specific settings so they don't get used on the
          // next call by mistake.
          this.settings = null;
        })
        .catch((error) =>
          // eslint-disable-next-line no-console
          console.error(
            Drupal.t(
              'An error occurred during the execution of the Ajax response: !error',
              {
                '!error': error,
              },
            ),
          ),
        )
    );
  };

  /**
@@ -1610,5 +1669,72 @@
      }
      messages.add(response.message, response.messageOptions);
    },

    /**
     * Command to add JS.
     *
     * @param {Drupal.Ajax} [ajax]
     *   {@link Drupal.Ajax} object created by {@link Drupal.ajax}.
     * @param {object} response
     *   The response from the Ajax request.
     * @param {Array} response.data
     *   An array of objects of script attributes.
     * @param {number} [status]
     *   The XMLHttpRequest status.
     */
    add_js(ajax, response, status) {
      const parentEl = document.querySelector(response.selector || 'body');
      const settings = ajax.settings || drupalSettings;
      const allUniqueBundleIds = response.data.map((script) => {
        // loadjs requires a unique ID, and an AJAX instance's `instanceIndex`
        // is guaranteed to be unique.
        // @see Drupal.behaviors.AJAX.detach
        const uniqueBundleId = script.src + ajax.instanceIndex;
        loadjs(script.src, uniqueBundleId, {
          // The default loadjs behavior is to load script with async, in Drupal
          // we need to explicitly tell scripts to load async, this is set in
          // the before callback below if necessary.
          async: false,
          before(path, scriptEl) {
            // This allows all attributes to be added, like defer, async and
            // crossorigin.
            Object.keys(script).forEach((attributeKey) => {
              scriptEl.setAttribute(attributeKey, script[attributeKey]);
            });

            // By default, loadjs appends the script to the head. When scripts
            // are loaded via AJAX, their location has no impact on
            // functionality. But, since non-AJAX loaded scripts can choose
            // their parent element, we provide that option here for the sake of
            // consistency.
            parentEl.appendChild(scriptEl);
            // Return false to bypass loadjs' default DOM insertion mechanism.
            return false;
          },
        });
        return uniqueBundleId;
      });
      // Returns the promise so that the next AJAX command waits on the
      // completion of this one to execute, ensuring the JS is loaded before
      // executing.
      return new Promise((resolve, reject) => {
        loadjs.ready(allUniqueBundleIds, {
          success() {
            Drupal.attachBehaviors(parentEl, settings);
            // All JS files were loaded and new and old behaviors have
            // been attached. Resolve the promise and let the remaining
            // commands execute.
            resolve();
          },
          error(depsNotFound) {
            const message = Drupal.t(
              `The following files could not be loaded: @dependencies`,
              { '@dependencies': depsNotFound.join(', ') },
            );
            reject(message);
          },
        });
      });
    },
  };
})(jQuery, window, Drupal, drupalSettings, window.tabbable);
})(jQuery, window, Drupal, drupalSettings, loadjs, window.tabbable);
+80 −26
Original line number Diff line number Diff line
@@ -5,7 +5,7 @@
* @preserve
**/

(function ($, window, Drupal, drupalSettings, _ref) {
(function ($, window, Drupal, drupalSettings, loadjs, _ref) {
  let {
    isFocusable,
    tabbable
@@ -229,12 +229,16 @@
          }
        }

        return ajax.success(response, status);
        return Promise.resolve(ajax.success(response, status)).then(() => {
          ajax.ajaxing = false;
        });
      },

      complete(xmlhttprequest, status) {
      error(xmlhttprequest, status, error) {
        ajax.ajaxing = false;
      },

      complete(xmlhttprequest, status) {
        if (status === 'error' || status === 'parsererror') {
          return ajax.error(xmlhttprequest, ajax.url);
        }
@@ -414,6 +418,19 @@
    $('body').append(this.progress.element);
  };

  Drupal.Ajax.prototype.commandExecutionQueue = function (response, status) {
    const ajaxCommands = this.commands;
    return Object.keys(response || {}).reduce((executionQueue, key) => executionQueue.then(() => {
      const {
        command
      } = response[key];

      if (command && ajaxCommands[command]) {
        return ajaxCommands[command](this, response[key], status);
      }
    }), Promise.resolve());
  };

  Drupal.Ajax.prototype.success = function (response, status) {
    if (this.progress.element) {
      $(this.progress.element).remove();
@@ -425,17 +442,14 @@

    $(this.element).prop('disabled', false);
    const elementParents = $(this.element).parents('[data-drupal-selector]').addBack().toArray();
    let focusChanged = false;
    Object.keys(response || {}).forEach(i => {
      if (response[i].command && this.commands[response[i].command]) {
        this.commands[response[i].command](this, response[i], status);

        if (response[i].command === 'invoke' && response[i].method === 'focus' || response[i].command === 'focusFirst') {
          focusChanged = true;
        }
      }
    const focusChanged = Object.keys(response || {}).some(key => {
      const {
        command,
        method
      } = response[key];
      return command === 'focusFirst' || command === 'invoke' && method === 'focus';
    });

    return this.commandExecutionQueue(response, status).then(() => {
      if (!focusChanged && this.element && !$(this.element).data('disable-refocus')) {
        let target = false;

@@ -454,6 +468,9 @@
      }

      this.settings = null;
    }).catch(error => console.error(Drupal.t('An error occurred during the execution of the Ajax response: !error', {
      '!error': error
    })));
  };

  Drupal.Ajax.prototype.getEffect = function (response) {
@@ -664,7 +681,44 @@
      }

      messages.add(response.message, response.messageOptions);
    },

    add_js(ajax, response, status) {
      const parentEl = document.querySelector(response.selector || 'body');
      const settings = ajax.settings || drupalSettings;
      const allUniqueBundleIds = response.data.map(script => {
        const uniqueBundleId = script.src + ajax.instanceIndex;
        loadjs(script.src, uniqueBundleId, {
          async: false,

          before(path, scriptEl) {
            Object.keys(script).forEach(attributeKey => {
              scriptEl.setAttribute(attributeKey, script[attributeKey]);
            });
            parentEl.appendChild(scriptEl);
            return false;
          }

        });
        return uniqueBundleId;
      });
      return new Promise((resolve, reject) => {
        loadjs.ready(allUniqueBundleIds, {
          success() {
            Drupal.attachBehaviors(parentEl, settings);
            resolve();
          },

          error(depsNotFound) {
            const message = Drupal.t(`The following files could not be loaded: @dependencies`, {
              '@dependencies': depsNotFound.join(', ')
            });
            reject(message);
          }

        });
      });
    }

  };
})(jQuery, window, Drupal, drupalSettings, window.tabbable);
 No newline at end of file
})(jQuery, window, Drupal, drupalSettings, loadjs, window.tabbable);
 No newline at end of file
Loading