Bug 1496425 - Provide a mechanism for Custom Elements to delay connectedCallback until after DOMContentLoaded;r=paolo
authorBrian Grinstead <bgrinstead@mozilla.com>
Mon, 08 Oct 2018 21:17:39 +0000
changeset 488449 7a32e3740263592cebceda4a55c3cf43caa88dfe
parent 488448 aab7bf199799d5c3c696fa5c369491bff062873f
child 488450 ffb0915bb97ef25af60e1f1cf021bafc0ba2d0c3
push id246
push userfmarier@mozilla.com
push dateSat, 13 Oct 2018 00:15:40 +0000
reviewerspaolo
bugs1496425
milestone64.0a1
Bug 1496425 - Provide a mechanism for Custom Elements to delay connectedCallback until after DOMContentLoaded;r=paolo There are two reasons for this: 1) It's faster than running the connectedCallback in the middle of document parse, at least for <radiogroups> in about:preferences 2) It provides a construction sequence more similar to XBL, so the translation from XBL <constructor> to CE connectedCallback is more likely to be correct. This is because when there is markup like: <parent-ce><child-ce></child-ce></parent-ce> the parent-ce node is empty during the first connectedCallback. If we wait for DOMContentLoaded then the parent-ce has the child-ce node below it. Differential Revision: https://phabricator.services.mozilla.com/D7944
toolkit/content/customElements.js
toolkit/content/tests/chrome/chrome.ini
toolkit/content/tests/chrome/test_custom_element_delay_connection.xul
toolkit/content/widgets/radio.js
--- a/toolkit/content/customElements.js
+++ b/toolkit/content/customElements.js
@@ -8,21 +8,64 @@
 
 // This is loaded into chrome windows with the subscript loader. Wrap in
 // a block to prevent accidentally leaking globals onto `window`.
 {
 
 ChromeUtils.import("resource://gre/modules/Services.jsm");
 ChromeUtils.import("resource://gre/modules/AppConstants.jsm");
 
+// The listener of DOMContentLoaded must be set on window, rather than
+// document, because the window can go away before the event is fired.
+// In that case, we don't want to initialize anything, otherwise we
+// may be leaking things because they will never be destroyed after.
+let gIsDOMContentLoaded = false;
+const gElementsPendingConnection = new Set();
+window.addEventListener("DOMContentLoaded", () => {
+  gIsDOMContentLoaded = true;
+  for (let element of gElementsPendingConnection) {
+    try {
+      if (element.isConnected) {
+        element.connectedCallback();
+      }
+    } catch (ex) { console.error(ex); }
+  }
+  gElementsPendingConnection.clear();
+}, { once: true, capture: true });
+
 const gXULDOMParser = new DOMParser();
 gXULDOMParser.forceEnableXULXBL();
 
 class MozXULElement extends XULElement {
   /**
+   * Sometimes an element may not want to run connectedCallback logic during
+   * parse. This could be because we don't want to initialize the element before
+   * the element's contents have been fully parsed, or for performance reasons.
+   * If you'd like to opt-in to this, then add this to the beginning of your
+   * `connectedCallback` and `disconnectedCallback`:
+   *
+   *    if (this.delayConnectedCallback()) { return }
+   *
+   * And this at the beginning of your `attributeChangedCallback`
+   *
+   *    if (!this.isConnectedAndReady) { return; }
+   */
+  delayConnectedCallback() {
+    if (gIsDOMContentLoaded) {
+      return false;
+    }
+    gElementsPendingConnection.add(this);
+    return true;
+  }
+
+  get isConnectedAndReady() {
+    return gIsDOMContentLoaded && this.isConnected;
+  }
+
+  /**
    * Allows eager deterministic construction of XUL elements with XBL attached, by
    * parsing an element tree and returning a DOM fragment to be inserted in the
    * document before any of the inner elements is referenced by JavaScript.
    *
    * This process is required instead of calling the createElement method directly
    * because bindings get attached when:
    *
    * 1. the node gets a layout frame constructed, or
--- a/toolkit/content/tests/chrome/chrome.ini
+++ b/toolkit/content/tests/chrome/chrome.ini
@@ -100,16 +100,17 @@ support-files = bug451540_window.xul
 skip-if = (os == 'mac' && os_version == '10.10') # Unexpectedly perma-passes on OSX 10.10
 [test_bug792324.xul]
 [test_bug1048178.xul]
 skip-if = toolkit == "cocoa"
 [test_button.xul]
 [test_closemenu_attribute.xul]
 [test_contextmenu_list.xul]
 [test_custom_element_base.xul]
+[test_custom_element_delay_connection.xul]
 [test_deck.xul]
 [test_dialogfocus.xul]
 [test_editor_for_input_with_autocomplete.html]
 [test_editor_for_textbox_with_autocomplete.xul]
 [test_findbar.xul]
 subsuite = clipboard
 [test_findbar_entireword.xul]
 [test_findbar_events.xul]
new file mode 100644
--- /dev/null
+++ b/toolkit/content/tests/chrome/test_custom_element_delay_connection.xul
@@ -0,0 +1,110 @@
+<?xml version="1.0"?>
+<?xml-stylesheet href="chrome://global/skin" type="text/css"?>
+<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?>
+
+<window title="Custom Element Base Delayed Connected"
+  onload="runTests();"
+  xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+
+  <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+  <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script>
+
+  <!-- test results are displayed in the html:body -->
+  <body xmlns="http://www.w3.org/1999/xhtml" style="height: 300px; overflow: auto;"/>
+
+  <script type="application/javascript"><![CDATA[
+  let nativeDOMContentLoadedFired = false;
+  document.addEventListener("DOMContentLoaded", () => {
+    nativeDOMContentLoadedFired = true;
+  }, { once: true });
+
+  // To test `delayConnectedCallback` and `isConnectedAndReady` we have to run this before
+  // DOMContentLoaded, which is why this is done in a separate script that runs
+  // immediately and not in `runTests`.
+  let delayedConnectionPromise = new Promise(resolve => {
+
+    let numSkippedAttributeChanges = 0;
+    let numDelayedConnections = 0;
+    let numDelayedDisconnections = 0;
+    let finishedWaitingForDOMReady = false;
+
+    // Register this custom element before DOMContentLoaded has fired and before it's parsed in
+    // the markup:
+    customElements.define("delayed-connection", class DelayedConnection extends MozXULElement {
+      static get observedAttributes() { return ["foo"]; }
+      attributeChangedCallback() {
+        ok(!this.isConnectedAndReady,
+           "attributeChangedCallback fires before isConnectedAndReady");
+        ok(!nativeDOMContentLoadedFired,
+           "attributeChangedCallback fires before nativeDOMContentLoadedFired");
+        numSkippedAttributeChanges++;
+      }
+      connectedCallback() {
+        if (this.delayConnectedCallback()) {
+          ok(!finishedWaitingForDOMReady,
+             "connectedCallback with delayConnectedCallback fires before finishedWaitingForDOMReady");
+          ok(!this.isConnectedAndReady,
+             "connectedCallback with delayConnectedCallback fires before isConnectedAndReady");
+          ok(!nativeDOMContentLoadedFired,
+             "connectedCallback with delayConnectedCallback fires before nativeDOMContentLoadedFired");
+          numDelayedConnections++;
+          return;
+        }
+
+        ok(!finishedWaitingForDOMReady,
+           "connectedCallback only fires once when DOM is ready");
+        ok(this.isConnectedAndReady,
+           "isConnectedAndReady during connectedCallback");
+        ok(!nativeDOMContentLoadedFired,
+           "delayed connectedCallback fires before nativeDOMContentLoadedFired");
+
+        is(numSkippedAttributeChanges, 2,
+           "Correct number of skipped attribute changes");
+        is(numDelayedConnections, 2,
+           "Correct number of delayed connections");
+        is(numDelayedDisconnections, 1,
+           "Correct number of delated disconnections");
+
+        finishedWaitingForDOMReady = true;
+        resolve();
+      }
+      disconnectedCallback() {
+        ok(this.delayConnectedCallback(),
+           "disconnectedCallback while DOM not ready");
+        is(numDelayedDisconnections, 0,
+           "disconnectedCallback fired only once");
+        numDelayedDisconnections++;
+      }
+    });
+  });
+
+  // This should be called after the element is parsed below this.
+  function mutateDelayedConnection() {
+    // Fire connectedCallback and attributeChangedCallback twice before DOMContentLoaded
+    // fires. The first connectedCallback is due to the parse and the second due to re-appending.
+    let delayedConnection = document.querySelector("delayed-connection");
+    delayedConnection.setAttribute("foo", "bar");
+    delayedConnection.remove();
+    delayedConnection.setAttribute("foo", "bat");
+    document.documentElement.append(delayedConnection);
+  }
+  ]]>
+  </script>
+
+  <delayed-connection></delayed-connection>
+
+  <!-- test code goes here -->
+  <script type="application/javascript"><![CDATA[
+  SimpleTest.waitForExplicitFinish();
+  mutateDelayedConnection();
+
+  async function runTests() {
+    info("Waiting for delayed connection to fire");
+    ok(nativeDOMContentLoadedFired,
+       "nativeDOMContentLoadedFired is true in runTests");
+    await delayedConnectionPromise;
+    SimpleTest.finish();
+  }
+  ]]>
+  </script>
+</window>
\ No newline at end of file
--- a/toolkit/content/widgets/radio.js
+++ b/toolkit/content/widgets/radio.js
@@ -107,16 +107,20 @@ class MozRadiogroup extends MozBaseContr
         return;
       }
       this.removeAttribute("focused");
       this.focusedItem = null;
     });
   }
 
   connectedCallback() {
+    if (this.delayConnectedCallback()) {
+      return;
+    }
+
     this.init();
     if (!this.value) {
       this.selectedIndex = 0;
     }
   }
 
   init() {
     this._radioChildren = null;