diff --git a/modules/refreshless_turbo/js/drupal_settings.js b/modules/refreshless_turbo/js/drupal_settings.js index ae0bedb21d4e2e70f97e5acd0d764efc264e0358..27820af33053de213187055e06c59ebebcc9433a 100644 --- a/modules/refreshless_turbo/js/drupal_settings.js +++ b/modules/refreshless_turbo/js/drupal_settings.js @@ -7,6 +7,115 @@ */ class DrupalSettings { + /** + * Zero more Drupal settings JSON elements found in the DOM. + * + * @type {HTMLScriptElement[]} + */ + #foundElements = []; + + /** + * <script> element MutationObserver. + * + * @type {MutationObserver} + */ + #scriptObserver; + + constructor() { + + /** + * Reference to the current instance. + * + * @type {DrupalSettings} + */ + const that = this; + + /** + * Reference to our #scriptObserverCallback private method. + * + * @type {Function} + */ + const scriptObserverCallback = this.#scriptObserverCallback; + + this.#scriptObserver = new MutationObserver(function(mutations) { + scriptObserverCallback.call(that, mutations); + }); + + /** + * Reference to our <script> element MutationObserver. + * + * @type {MutationObserver} + */ + const scriptObserver = this.#scriptObserver; + + document.documentElement.addEventListener('turbo:visit', function(event) { + + // Reset the array of found script elements before starting the + // MutationObserver. + that.#foundElements = []; + + // Start observing when a Turbo visit starts. This event is triggered + // before any changes are made to the DOM, including the <head>, so no + // new <script> elements will have been merged in yet. + // + // Also note that while this <script> element can be rendered by Drupal + // in the <body>, we explicitly move JavaScript to the <head> which also + // causes Drupal to render this <script> in the <head> as well, so we + // don't observe the <body>. + scriptObserver.observe(document.head, { + childList: true, + // Important: do *not* use subtree: true as that will open up + // potential XSS exploits. + // + // @see core/misc/drupalSettingsLoader.js + // Explains the need for XSS hardening. + }); + + }); + + }; + + /** + * <script> element MutationObserver callback. + * + * @param {MutationRecord} mutations + */ + #scriptObserverCallback(mutations) { + + for (let i = 0; i < mutations.length; i++) { + + if (!('addedNodes' in mutations[i])) { + continue; + } + + /** + * Nodes added as part of this mutation record. + * + * @type {NodeList} + */ + const addedNodes = mutations[i].addedNodes; + + for (let j = 0; j < addedNodes.length; j++) { + + // Ignore any non-<script> element nodes or <script> elements not + // matching the selector for the drupalSettings JSON element. + if ( + !(addedNodes[j] instanceof HTMLScriptElement) || + addedNodes[j].getAttribute( + 'data-drupal-selector', + ) !== 'drupal-settings-json' + ) { + continue; + } + + this.#foundElements.push(addedNodes[j]); + + } + + } + + }; + /** * Find the Drupal settings JSON element(s). * @@ -39,13 +148,36 @@ * merging? * * @throws Error - * If the settings element text could not be parsed into JSON. + * If no new settings elements were added, if more than one element was + * added, or if the new settings element text could not be parsed into + * valid JSON. * * @return {Promise} * A Promise that fulfills when drupalSettings has been updated. */ update() { + // Stop observing for new <script> elements at this point because our job + // is done and we don't want to do unnecessary work, potentially + // affecting performance. + this.#scriptObserver.disconnect(); + + if (this.#foundElements.length === 0) { + + console.error(Drupal.t( + 'Could not find a Drupal settings script element added during the visit!', + )); + + } + + if (this.#foundElements.length > 1) { + + console.error(Drupal.t( + 'More than one Drupal settings script element seems to have been added!', + ), this.#foundElements); + + } + /** * One or more settings elements. * @@ -53,10 +185,10 @@ */ const $elements = this.#findElements(); - // Remove all but the last settings element. - $elements.not($elements.last()).remove(); + // Remove all but the newest settings element. + $elements.not(this.#foundElements[0]).remove(); - const $element = $elements.last(); + const $element = $(this.#foundElements[0]); /** * New drupalSettings values or null if they can't be read.