diff --git a/core/misc/ajax.js b/core/misc/ajax.js index 4c6e3da9668245bda177276e9814500ae0e7db38..25263c539bdedf5fec99a57daebb86d12d227131 100644 --- a/core/misc/ajax.js +++ b/core/misc/ajax.js @@ -505,6 +505,9 @@ ajax.options = { url: ajax.url, data: ajax.submit, + isInProgress() { + return ajax.ajaxing; + }, beforeSerialize(elementSettings, options) { return ajax.beforeSerialize(elementSettings, options); }, @@ -552,6 +555,17 @@ // finished executing. .then(() => { ajax.ajaxing = false; + // jQuery normally triggers the ajaxSuccess, ajaxComplete, and + // ajaxStop events after the "success" function passed to $.ajax() + // returns, but we prevented that via + // $.event.special[EVENT_NAME].trigger in order to wait for the + // commands to finish executing. Now that they have, re-trigger + // those events. + $(document).trigger('ajaxSuccess', [xmlhttprequest, this]); + $(document).trigger('ajaxComplete', [xmlhttprequest, this]); + if (--$.active === 0) { + $(document).trigger('ajaxStop'); + } }) ); }, @@ -1735,4 +1749,50 @@ }); }, }; + + /** + * Delay jQuery's global completion events until after commands have executed. + * + * jQuery triggers the ajaxSuccess, ajaxComplete, and ajaxStop events after + * a successful response is returned and local success and complete events + * are triggered. However, Drupal Ajax responses contain commands that run + * asynchronously in a queue, so the following stops these events from getting + * triggered until after the Promise that executes the command queue is + * resolved. + */ + const stopEvent = (xhr, settings) => { + return ( + // Only interfere with Drupal's Ajax responses. + xhr.getResponseHeader('X-Drupal-Ajax-Token') === '1' && + // The isInProgress() function might not be defined if the Ajax request + // was initiated without Drupal.ajax() or new Drupal.Ajax(). + settings.isInProgress && + // Until this is false, the Ajax request isn't completely done (the + // response's commands might still be running). + settings.isInProgress() + ); + }; + $.extend(true, $.event.special, { + ajaxSuccess: { + trigger(event, xhr, settings) { + if (stopEvent(xhr, settings)) { + return false; + } + }, + }, + ajaxComplete: { + trigger(event, xhr, settings) { + if (stopEvent(xhr, settings)) { + // jQuery decrements its internal active ajax counter even when we + // stop the ajaxComplete event, but we don't want that counter + // decremented, because for our purposes this request is still active + // while commands are executing. By incrementing it here, the net + // effect is that it remains unchanged. By remaining above 0, the + // ajaxStop event is also prevented. + $.active++; + return false; + } + }, + }, + }); })(jQuery, window, Drupal, drupalSettings, loadjs, window.tabbable); diff --git a/core/modules/system/tests/modules/ajax_test/ajax_test.libraries.yml b/core/modules/system/tests/modules/ajax_test/ajax_test.libraries.yml index 14cbba106a567ca9ff66a5a45f95c9f2454c48ba..310c8392b0b2106d1b95b8e751afefb2415775ca 100644 --- a/core/modules/system/tests/modules/ajax_test/ajax_test.libraries.yml +++ b/core/modules/system/tests/modules/ajax_test/ajax_test.libraries.yml @@ -42,3 +42,9 @@ command_promise: - core/jquery - core/drupal - core/drupal.ajax + +global_events: + js: + js/global_events.js: {} + dependencies: + - core/drupal.ajax diff --git a/core/modules/system/tests/modules/ajax_test/ajax_test.routing.yml b/core/modules/system/tests/modules/ajax_test/ajax_test.routing.yml index 0ee765e664bc75f85d398e8080d44a1c869f1040..a90279ea9bdadbadc860431ec0bd40a5b7447fa8 100644 --- a/core/modules/system/tests/modules/ajax_test/ajax_test.routing.yml +++ b/core/modules/system/tests/modules/ajax_test/ajax_test.routing.yml @@ -101,3 +101,17 @@ ajax_test.promise: _form: '\Drupal\ajax_test\Form\AjaxTestFormPromise' requirements: _access: 'TRUE' + +ajax_test.global_events: + path: '/ajax-test/global-events' + defaults: + _controller: '\Drupal\ajax_test\Controller\AjaxTestController::globalEvents' + requirements: + _access: 'TRUE' + +ajax_test.global_events_clear_log: + path: '/ajax-test/global-events/clear-log' + defaults: + _controller: '\Drupal\ajax_test\Controller\AjaxTestController::globalEventsClearLog' + requirements: + _access: 'TRUE' diff --git a/core/modules/system/tests/modules/ajax_test/js/global_events.js b/core/modules/system/tests/modules/ajax_test/js/global_events.js new file mode 100644 index 0000000000000000000000000000000000000000..c2c3749fbcce8f56f62a1d8e2d80fd8a8c467eed --- /dev/null +++ b/core/modules/system/tests/modules/ajax_test/js/global_events.js @@ -0,0 +1,14 @@ +/** + * @file + * For testing that jQuery's ajaxSuccess, ajaxComplete, and ajaxStop events + * are triggered only after commands in a Drupal Ajax response are executed. + */ + +(($, Drupal) => { + ['ajaxSuccess', 'ajaxComplete', 'ajaxStop'].forEach((eventName) => { + $(document)[eventName](() => { + $('#test_global_events_log').append(eventName); + $('#test_global_events_log2').append(eventName); + }); + }); +})(jQuery, Drupal); diff --git a/core/modules/system/tests/modules/ajax_test/src/Controller/AjaxTestController.php b/core/modules/system/tests/modules/ajax_test/src/Controller/AjaxTestController.php index d02d26a49b809df664be025fae82b2ef99d58e22..efa9f6a919f3af5cb26468da740fa361fc25dba4 100644 --- a/core/modules/system/tests/modules/ajax_test/src/Controller/AjaxTestController.php +++ b/core/modules/system/tests/modules/ajax_test/src/Controller/AjaxTestController.php @@ -354,4 +354,37 @@ protected function getRenderTypes() { return $render_info; } + /** + * Returns a page from which to test Ajax global events. + * + * @return array + * The render array. + */ + public function globalEvents() { + return [ + '#attached' => [ + 'library' => [ + 'ajax_test/global_events', + ], + ], + '#markup' => implode('', [ + '<div id="test_global_events_log"></div>', + '<a id="test_global_events_drupal_ajax_link" class="use-ajax" href="' . Url::fromRoute('ajax_test.global_events_clear_log')->toString() . '">Drupal Ajax</a>', + '<div id="test_global_events_log2"></div>', + ]), + ]; + } + + /** + * Returns an AjaxResponse with command to clear the 'test_global_events_log'. + * + * @return \Drupal\Core\Ajax\AjaxResponse + * The JSON response object. + */ + public function globalEventsClearLog() { + $response = new AjaxResponse(); + $response->addCommand(new HtmlCommand('#test_global_events_log', '')); + return $response; + } + } diff --git a/core/tests/Drupal/FunctionalJavascriptTests/Ajax/AjaxTest.php b/core/tests/Drupal/FunctionalJavascriptTests/Ajax/AjaxTest.php index 7cd3c5a013c636e880d4abf2e176e9338779b2c6..6ee48152420f5da5afb895890493af8cef7cb5ce 100644 --- a/core/tests/Drupal/FunctionalJavascriptTests/Ajax/AjaxTest.php +++ b/core/tests/Drupal/FunctionalJavascriptTests/Ajax/AjaxTest.php @@ -153,6 +153,51 @@ public function testInsertAjaxResponse() { $this->assertInsert('empty', $expected, $custom_wrapper_new_content); } + /** + * Tests that jQuery's global Ajax events are triggered at the correct time. + */ + public function testGlobalEvents() { + $session = $this->getSession(); + $assert = $this->assertSession(); + $expected_event_order = implode('', ['ajaxSuccess', 'ajaxComplete', 'ajaxStop']); + + $this->drupalGet('ajax-test/global-events'); + + // Ensure that a non-Drupal Ajax request triggers the expected events, in + // the correct order, a single time. + $session->executeScript('jQuery.get(Drupal.url("core/COPYRIGHT.txt"))'); + $assert->assertWaitOnAjaxRequest(); + $assert->elementTextEquals('css', '#test_global_events_log', $expected_event_order); + $assert->elementTextEquals('css', '#test_global_events_log2', $expected_event_order); + + // Ensure that an Ajax request to a Drupal Ajax response, but that was not + // initiated with Drupal.Ajax(), triggers the expected events, in the + // correct order, a single time. We expect $expected_event_order to appear + // twice in each log element, because Drupal Ajax response commands (such + // as the one to clear the log element) are only executed for requests + // initiated with Drupal.Ajax(), and these elements already contain the + // text that was added above. + $session->executeScript('jQuery.get(Drupal.url("ajax-test/global-events/clear-log"))'); + $assert->assertWaitOnAjaxRequest(); + $assert->elementTextEquals('css', '#test_global_events_log', str_repeat($expected_event_order, 2)); + $assert->elementTextEquals('css', '#test_global_events_log2', str_repeat($expected_event_order, 2)); + + // Ensure that a Drupal Ajax request triggers the expected events, in the + // correct order, a single time. + // - We expect the first log element to list the events exactly once, + // because the Ajax response clears it, and we expect the events to be + // triggered after the commands are executed. + // - We expect the second log element to list the events exactly three + // times, because it already contains the two from the code that was + // already executed above. This additional log element that isn't cleared + // by the response's command ensures that the events weren't triggered + // additional times before the response commands were executed. + $this->click('#test_global_events_drupal_ajax_link'); + $assert->assertWaitOnAjaxRequest(); + $assert->elementTextEquals('css', '#test_global_events_log', $expected_event_order); + $assert->elementTextEquals('css', '#test_global_events_log2', str_repeat($expected_event_order, 3)); + } + /** * Assert insert. *