summaryrefslogtreecommitdiffstatshomepage
diff options
context:
space:
mode:
authorLauri Eskola <lauri.eskola@acquia.com>2022-09-23 17:55:23 +0300
committerLauri Eskola <lauri.eskola@acquia.com>2022-09-23 17:55:59 +0300
commit6da66e99da259b807c300362f85b978df1fe64d4 (patch)
tree8f84f201d691214aac684e4263393e8add54c17a
parent5496d64a4a906b2e057a49d6c819c92f5cb73e15 (diff)
downloaddrupal-6da66e99da259b807c300362f85b978df1fe64d4.tar.gz
drupal-6da66e99da259b807c300362f85b978df1fe64d4.zip
Issue #3196973 by casey, nod_, andypost, yogeshmpawar, droplet, Wim Leers, justafish, finnsky: Use Mutation observer for BigPipe replacements
-rw-r--r--core/modules/big_pipe/big_pipe.libraries.yml1
-rw-r--r--core/modules/big_pipe/js/big_pipe.js177
-rw-r--r--core/modules/ckeditor5/js/ckeditor5.admin.js118
-rw-r--r--core/profiles/standard/tests/src/FunctionalJavascript/StandardJavascriptTest.php3
4 files changed, 151 insertions, 148 deletions
diff --git a/core/modules/big_pipe/big_pipe.libraries.yml b/core/modules/big_pipe/big_pipe.libraries.yml
index f5774ef9750..36d5f8e4dcc 100644
--- a/core/modules/big_pipe/big_pipe.libraries.yml
+++ b/core/modules/big_pipe/big_pipe.libraries.yml
@@ -5,6 +5,5 @@ big_pipe:
drupalSettings:
bigPipePlaceholderIds: []
dependencies:
- - core/once
- core/drupal.ajax
- core/drupalSettings
diff --git a/core/modules/big_pipe/js/big_pipe.js b/core/modules/big_pipe/js/big_pipe.js
index 097a036472d..ea4f6f8cfb1 100644
--- a/core/modules/big_pipe/js/big_pipe.js
+++ b/core/modules/big_pipe/js/big_pipe.js
@@ -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'
- ) {
- 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');
- }
+ if (typeof drupalSettings.bigPipePlaceholderIds[id] === 'undefined') {
+ 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;
+ // Immediately remove the replacement to prevent it being processed twice.
+ delete drupalSettings.bigPipePlaceholderIds[id];
+
+ const response = mapTextContentToAjaxResponse(content);
- // The internal ID to contain the watcher service.
- let timeoutID;
+ if (response === false) {
+ return;
+ }
+
+ // 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.
- *
- * @param {HTMLDocument} context
- * The HTML document containing <script type="application/vnd.drupal-ajax">
- * tags generated by BigPipe.
+ * Check that the element is valid to process and process it.
*
- * @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;
- }
+ const observer = new MutationObserver(processMutations);
- return false;
- }
+ // Attach behaviors early, if possible.
+ Drupal.attachBehaviors(document.body);
- function bigPipeProcess() {
- timeoutID = setTimeout(() => {
- if (!bigPipeProcessDocument(document)) {
- bigPipeProcess();
- }
- }, interval);
- }
+ // 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);
diff --git a/core/modules/ckeditor5/js/ckeditor5.admin.js b/core/modules/ckeditor5/js/ckeditor5.admin.js
index c996d2f506f..5368736f4df 100644
--- a/core/modules/ckeditor5/js/ckeditor5.admin.js
+++ b/core/modules/ckeditor5/js/ckeditor5.admin.js
@@ -530,69 +530,71 @@
*/
Drupal.behaviors.ckeditor5Admin = {
attach(context) {
- once('ckeditor5-admin-toolbar', '#ckeditor5-toolbar-app').forEach(
- (container) => {
- const selectedTextarea = context.querySelector(
- '#ckeditor5-toolbar-buttons-selected',
- );
- const available = Object.entries(
- JSON.parse(
- context.querySelector('#ckeditor5-toolbar-buttons-available')
- .innerHTML,
- ),
- ).map(([name, attrs]) => ({ name, id: name, ...attrs }));
- const dividers = [
- {
- id: 'divider',
- name: '|',
- label: Drupal.t('Divider'),
- },
- {
- id: 'wrapping',
- name: '-',
- label: Drupal.t('Wrapping'),
- },
- ];
+ once(
+ 'ckeditor5-admin-toolbar',
+ '#ckeditor5-toolbar-app',
+ context,
+ ).forEach((container) => {
+ const selectedTextarea = context.querySelector(
+ '#ckeditor5-toolbar-buttons-selected',
+ );
+ const available = Object.entries(
+ JSON.parse(
+ context.querySelector('#ckeditor5-toolbar-buttons-available')
+ .innerHTML,
+ ),
+ ).map(([name, attrs]) => ({ name, id: name, ...attrs }));
+ const dividers = [
+ {
+ id: 'divider',
+ name: '|',
+ label: Drupal.t('Divider'),
+ },
+ {
+ id: 'wrapping',
+ name: '-',
+ label: Drupal.t('Wrapping'),
+ },
+ ];
- // Selected is used for managing the state. Sortable is handling updates
- // to the state when the system is operated by mouse. There are
- // functions making direct modifications to the state when system is
- // operated by keyboard.
- const selected = new Observable(
- JSON.parse(selectedTextarea.innerHTML).map((name) => {
- return [...dividers, ...available].find((button) => {
- return button.name === name;
- }).id;
- }),
- );
+ // Selected is used for managing the state. Sortable is handling updates
+ // to the state when the system is operated by mouse. There are
+ // functions making direct modifications to the state when system is
+ // operated by keyboard.
+ const selected = new Observable(
+ JSON.parse(selectedTextarea.innerHTML).map((name) => {
+ return [...dividers, ...available].find((button) => {
+ return button.name === name;
+ }).id;
+ }),
+ );
- const mapSelection = (selection) => {
- return selection.map((id) => {
- return [...dividers, ...available].find((button) => {
- return button.id === id;
- }).name;
- });
- };
- // Whenever the state is changed, update the textarea with the changes.
- // This will also trigger re-render of the admin UI to reinitialize the
- // Sortable state.
- selected.subscribe((selection) => {
- updateSelectedButtons(mapSelection(selection), selectedTextarea);
- render(container, selected, available, dividers);
+ const mapSelection = (selection) => {
+ return selection.map((id) => {
+ return [...dividers, ...available].find((button) => {
+ return button.id === id;
+ }).name;
});
+ };
+ // Whenever the state is changed, update the textarea with the changes.
+ // This will also trigger re-render of the admin UI to reinitialize the
+ // Sortable state.
+ selected.subscribe((selection) => {
+ updateSelectedButtons(mapSelection(selection), selectedTextarea);
+ render(container, selected, available, dividers);
+ });
- [
- context.querySelector('#ckeditor5-toolbar-buttons-available'),
- context.querySelector('[class*="editor-settings-toolbar-items"]'),
- ]
- .filter((el) => el)
- .forEach((el) => {
- el.classList.add('visually-hidden');
- });
+ [
+ context.querySelector('#ckeditor5-toolbar-buttons-available'),
+ context.querySelector('[class*="editor-settings-toolbar-items"]'),
+ ]
+ .filter((el) => el)
+ .forEach((el) => {
+ el.classList.add('visually-hidden');
+ });
- render(container, selected, available, dividers);
- },
- );
+ 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
diff --git a/core/profiles/standard/tests/src/FunctionalJavascript/StandardJavascriptTest.php b/core/profiles/standard/tests/src/FunctionalJavascript/StandardJavascriptTest.php
index cec79deacf5..7f4bf798567 100644
--- a/core/profiles/standard/tests/src/FunctionalJavascript/StandardJavascriptTest.php
+++ b/core/profiles/standard/tests/src/FunctionalJavascript/StandardJavascriptTest.php
@@ -54,7 +54,8 @@ class StandardJavascriptTest extends WebDriverTestBase {
$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]'));
}