diff options
author | Lauri Eskola <lauri.eskola@acquia.com> | 2022-09-23 17:55:23 +0300 |
---|---|---|
committer | Lauri Eskola <lauri.eskola@acquia.com> | 2022-09-23 17:55:59 +0300 |
commit | 6da66e99da259b807c300362f85b978df1fe64d4 (patch) | |
tree | 8f84f201d691214aac684e4263393e8add54c17a | |
parent | 5496d64a4a906b2e057a49d6c819c92f5cb73e15 (diff) | |
download | drupal-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.yml | 1 | ||||
-rw-r--r-- | core/modules/big_pipe/js/big_pipe.js | 177 | ||||
-rw-r--r-- | core/modules/ckeditor5/js/ckeditor5.admin.js | 118 | ||||
-rw-r--r-- | core/profiles/standard/tests/src/FunctionalJavascript/StandardJavascriptTest.php | 3 |
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]')); } |