Issue #3196973 by casey, nod_, andypost, yogeshmpawar, droplet, Wim Leers, justafish, finnsky: Use Mutation observer for BigPipe replacements

merge-requests/2673/merge
Lauri Eskola 2022-09-23 17:55:23 +03:00
parent 5496d64a4a
commit 6da66e99da
No known key found for this signature in database
GPG Key ID: 382FC0F5B0DF53F8
4 changed files with 154 additions and 151 deletions

View File

@ -5,6 +5,5 @@ big_pipe:
drupalSettings: drupalSettings:
bigPipePlaceholderIds: [] bigPipePlaceholderIds: []
dependencies: dependencies:
- core/once
- core/drupal.ajax - core/drupal.ajax
- core/drupalSettings - core/drupalSettings

View File

@ -3,15 +3,39 @@
* Renders BigPipe placeholders using Drupal's Ajax system. * 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 * @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} * @return {Array|boolean}
* The parsed Ajax response containing an array of Ajax commands, or false in * The parsed Ajax response containing an array of Ajax commands, or false
* case the DOM node hasn't fully arrived yet. * in case the DOM node hasn't fully arrived yet.
*/ */
function mapTextContentToAjaxResponse(content) { function mapTextContentToAjaxResponse(content) {
if (content === '') { if (content === '') {
@ -30,108 +54,85 @@
* *
* These Ajax commands replace placeholders with HTML and load missing CSS/JS. * These Ajax commands replace placeholders with HTML and load missing CSS/JS.
* *
* @param {HTMLScriptElement} placeholderReplacement * @param {HTMLScriptElement} replacement
* Script tag created by BigPipe. * Script tag created by BigPipe.
*/ */
function bigPipeProcessPlaceholderReplacement(placeholderReplacement) { function processReplacement(replacement) {
const placeholderId = placeholderReplacement.getAttribute( const id = replacement.dataset.bigPipeReplacementForPlaceholderWithId;
'data-big-pipe-replacement-for-placeholder-with-id', // Because we use a mutation observer the content is guaranteed to be
); // complete at this point.
const content = placeholderReplacement.textContent.trim(); const content = replacement.textContent.trim();
// Ignore any placeholders that are not in the known placeholder list. Used // Ignore any placeholders that are not in the known placeholder list. Used
// to avoid someone trying to XSS the site via the placeholdering mechanism. // to avoid someone trying to XSS the site via the placeholdering mechanism.
if ( if (typeof drupalSettings.bigPipePlaceholderIds[id] === 'undefined') {
typeof drupalSettings.bigPipePlaceholderIds[placeholderId] !== 'undefined' return;
) {
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');
}
} }
// Immediately remove the replacement to prevent it being processed twice.
delete drupalSettings.bigPipePlaceholderIds[id];
const response = mapTextContentToAjaxResponse(content);
if (response === false) {
return;
}
// Then, simulate an AJAX response having arrived, and let the Ajax system
// handle it.
ajaxObject.success(response, 'success');
} }
// 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;
/** /**
* Processes a streamed HTML document receiving placeholder replacements. * Check that the element is valid to process and process it.
* *
* @param {HTMLDocument} context * @param {HTMLElement} node
* The HTML document containing <script type="application/vnd.drupal-ajax"> * The node added to the body element.
* tags generated by BigPipe.
*
* @return {bool}
* Returns true when processing has been finished and a stop signal has been
* found.
*/ */
function bigPipeProcessDocument(context) { function checkMutationAndProcess(node) {
// Make sure we have BigPipe-related scripts before processing further. if (
if (!context.querySelector('script[data-big-pipe-event="start"]')) { node.nodeType === Node.ELEMENT_NODE &&
return false; 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) => { * Handles the mutation callback.
Drupal.attachBehaviors(el); *
* @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;
} }
function bigPipeProcess() { const observer = new MutationObserver(processMutations);
timeoutID = setTimeout(() => {
if (!bigPipeProcessDocument(document)) {
bigPipeProcess();
}
}, interval);
}
bigPipeProcess(); // Attach behaviors early, if possible.
Drupal.attachBehaviors(document.body);
// If something goes wrong, make sure everything is cleaned up and has had a // If loaded asynchronously there might already be replacement elements
// chance to be processed with everything loaded. // in the DOM before the mutation observer is started.
window.addEventListener('load', () => { document.querySelectorAll(replacementsSelector).forEach(processReplacement);
if (timeoutID) {
clearTimeout(timeoutID); // Start observing the body element for new children.
observer.observe(document.body, { childList: true });
// 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); })(Drupal, drupalSettings);

View File

@ -530,69 +530,71 @@
*/ */
Drupal.behaviors.ckeditor5Admin = { Drupal.behaviors.ckeditor5Admin = {
attach(context) { attach(context) {
once('ckeditor5-admin-toolbar', '#ckeditor5-toolbar-app').forEach( once(
(container) => { 'ckeditor5-admin-toolbar',
const selectedTextarea = context.querySelector( '#ckeditor5-toolbar-app',
'#ckeditor5-toolbar-buttons-selected', context,
); ).forEach((container) => {
const available = Object.entries( const selectedTextarea = context.querySelector(
JSON.parse( '#ckeditor5-toolbar-buttons-selected',
context.querySelector('#ckeditor5-toolbar-buttons-available') );
.innerHTML, const available = Object.entries(
), JSON.parse(
).map(([name, attrs]) => ({ name, id: name, ...attrs })); context.querySelector('#ckeditor5-toolbar-buttons-available')
const dividers = [ .innerHTML,
{ ),
id: 'divider', ).map(([name, attrs]) => ({ name, id: name, ...attrs }));
name: '|', const dividers = [
label: Drupal.t('Divider'), {
}, id: 'divider',
{ name: '|',
id: 'wrapping', label: Drupal.t('Divider'),
name: '-', },
label: Drupal.t('Wrapping'), {
}, id: 'wrapping',
]; name: '-',
label: Drupal.t('Wrapping'),
},
];
// Selected is used for managing the state. Sortable is handling updates // Selected is used for managing the state. Sortable is handling updates
// to the state when the system is operated by mouse. There are // to the state when the system is operated by mouse. There are
// functions making direct modifications to the state when system is // functions making direct modifications to the state when system is
// operated by keyboard. // operated by keyboard.
const selected = new Observable( const selected = new Observable(
JSON.parse(selectedTextarea.innerHTML).map((name) => { JSON.parse(selectedTextarea.innerHTML).map((name) => {
return [...dividers, ...available].find((button) => { return [...dividers, ...available].find((button) => {
return button.name === name; return button.name === name;
}).id; }).id;
}), }),
); );
const mapSelection = (selection) => { const mapSelection = (selection) => {
return selection.map((id) => { return selection.map((id) => {
return [...dividers, ...available].find((button) => { return [...dividers, ...available].find((button) => {
return button.id === id; return button.id === id;
}).name; }).name;
}); });
}; };
// Whenever the state is changed, update the textarea with the changes. // Whenever the state is changed, update the textarea with the changes.
// This will also trigger re-render of the admin UI to reinitialize the // This will also trigger re-render of the admin UI to reinitialize the
// Sortable state. // Sortable state.
selected.subscribe((selection) => { selected.subscribe((selection) => {
updateSelectedButtons(mapSelection(selection), selectedTextarea); updateSelectedButtons(mapSelection(selection), selectedTextarea);
render(container, selected, available, dividers); 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');
}); });
[ 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');
});
render(container, selected, available, dividers);
},
);
// Safari's focus outlines take into account absolute positioned elements. // Safari's focus outlines take into account absolute positioned elements.
// When a toolbar option is blurred, the portion of the focus outline // When a toolbar option is blurred, the portion of the focus outline
// surrounding the absolutely positioned tooltip does not go away. To // surrounding the absolutely positioned tooltip does not go away. To

View File

@ -54,7 +54,8 @@ class StandardJavascriptTest extends WebDriverTestBase {
$web_assert = $this->assertSession(); $web_assert = $this->assertSession();
$web_assert->waitForElement('css', 'script[data-big-pipe-event="stop"]'); $web_assert->waitForElement('css', 'script[data-big-pipe-event="stop"]');
$page = $this->getSession()->getPage(); $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]')); $this->assertCount($expected_count, $page->findAll('css', 'script[data-big-pipe-replacement-for-placeholder-with-id]'));
} }