Issue #3196973 by casey, nod_, andypost, yogeshmpawar, droplet, Wim Leers, justafish, finnsky: Use Mutation observer for BigPipe replacements
parent
5496d64a4a
commit
6da66e99da
|
@ -5,6 +5,5 @@ big_pipe:
|
||||||
drupalSettings:
|
drupalSettings:
|
||||||
bigPipePlaceholderIds: []
|
bigPipePlaceholderIds: []
|
||||||
dependencies:
|
dependencies:
|
||||||
- core/once
|
|
||||||
- core/drupal.ajax
|
- core/drupal.ajax
|
||||||
- core/drupalSettings
|
- core/drupalSettings
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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]'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue