summaryrefslogtreecommitdiffstatshomepage
path: root/core/modules/big_pipe/js/big_pipe.js
blob: 3c3e106e70373886175161fca11d3dbbbf739e12 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
/**
 * @file
 * Renders BigPipe placeholders using Drupal's Ajax system.
 */

((Drupal, drupalSettings) => {
  /**
   * 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.
   * @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.
   */
  function mapTextContentToAjaxResponse(content) {
    if (content === '') {
      return false;
    }

    try {
      return JSON.parse(content);
    } catch (e) {
      return false;
    }
  }

  /**
   * Executes Ajax commands in <script type="application/vnd.drupal-ajax"> tag.
   *
   * These Ajax commands replace placeholders with HTML and load missing CSS/JS.
   *
   * @param {HTMLScriptElement} replacement
   *   Script tag created by BigPipe.
   */
  function processReplacement(replacement) {
    const id = replacement.dataset.bigPipeReplacementForPlaceholderWithId;
    // The content is not guaranteed to be complete at this point, but trimming
    // it will not make a big change, since json will not be valid if it was
    // not fully loaded anyway.
    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[id] === 'undefined') {
      return;
    }

    const response = mapTextContentToAjaxResponse(content);

    if (response === false) {
      return;
    }

    // Immediately remove the replacement to prevent it being processed twice.
    delete drupalSettings.bigPipePlaceholderIds[id];

    // Then, simulate an AJAX response having arrived, and let the Ajax system
    // handle it.
    ajaxObject.success(response, 'success');
  }

  /**
   * Checks if node is valid big pipe replacement.
   */
  function checkMutation(node) {
    return Boolean(
      node.nodeType === Node.ELEMENT_NODE &&
        node.nodeName === 'SCRIPT' &&
        node.dataset &&
        node.dataset.bigPipeReplacementForPlaceholderWithId &&
        typeof drupalSettings.bigPipePlaceholderIds[
          node.dataset.bigPipeReplacementForPlaceholderWithId
        ] !== 'undefined',
    );
  }

  /**
   * Check that the element is valid to process and process it.
   *
   * @param {HTMLElement} node
   *  The node added to the body element.
   */
  function checkMutationAndProcess(node) {
    if (checkMutation(node)) {
      processReplacement(node);
    }
    // Checks if parent node of target node has not been processed, which can
    // occur if the script node was first observed with empty content and then
    // the child text node was added in full later.
    // @see `@ingroup large_chunk` for more information.
    // If an element is added and then immediately (faster than the next
    // setImmediate is triggered) removed to a watched element of a
    // MutationObserver, the observer will notice and add a mutation for both
    // the addedNode and the removedNode - but the referenced element will not
    // have a parent node.
    else if (node.parentNode !== null && checkMutation(node.parentNode)) {
      processReplacement(node.parentNode);
    }
  }

  /**
   * Handles the mutation callback.
   *
   * @param {MutationRecord[]} mutations
   *  The list of mutations registered by the browser.
   */
  function processMutations(mutations) {
    mutations.forEach(({ addedNodes, type, target }) => {
      addedNodes.forEach(checkMutationAndProcess);

      // Checks if parent node of target node has not been processed.
      // @see `@ingroup large_chunk` for more information.
      if (
        type === 'characterData' &&
        checkMutation(target.parentNode) &&
        drupalSettings.bigPipePlaceholderIds[
          target.parentNode.dataset.bigPipeReplacementForPlaceholderWithId
        ] === true
      ) {
        processReplacement(target.parentNode);
      }
    });
  }

  const observer = new MutationObserver(processMutations);

  // Attach behaviors early, if possible.
  Drupal.attachBehaviors(document);

  // If loaded asynchronously there might already be replacement elements
  // in the DOM before the mutation observer is started.
  document.querySelectorAll(replacementsSelector).forEach(processReplacement);

  // Start observing the body element for new children and for new changes in
  // Text nodes of elements. We need to track Text nodes because content
  // of the node can be too large, browser will receive not fully loaded chunk
  // and render it as is. At this moment json inside script will be invalid and
  // we need to track new changes to that json (Text node), once it will be
  // fully loaded it will be processed.
  // @ingroup large_chunk
  observer.observe(document.body, {
    childList: true,
    // Without this options characterData will not be triggered inside child nodes.
    subtree: true,
    characterData: 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);
    }
    // No more mutations will be processed, remove the leftover Ajax object.
    Drupal.ajax.instances[ajaxObject.instanceIndex] = null;
  });
})(Drupal, drupalSettings);