Bug 1527680 - Add MozElement `inheritedAttributes` helper for automatically inheriting attributes based on a map of selectors to attributes r=paolo
☠☠ backed out by 79d9076cbaa1 ☠ ☠
authorBrian Grinstead <bgrinstead@mozilla.com>
Fri, 15 Feb 2019 16:33:00 +0000
changeset 459562 66082f22ae11643b018ed3affe705bb9b22420ff
parent 459561 27ab5f5e1fd4c759a4161b2a3dad96254292a0ed
child 459563 3f3f8c7cd7c542e6850a38acd0bde76c14865fec
push id35563
push userccoroiu@mozilla.com
push dateSat, 16 Feb 2019 09:36:04 +0000
treeherdermozilla-central@1cfd69d05aa1 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerspaolo
bugs1527680
milestone67.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1527680 - Add MozElement `inheritedAttributes` helper for automatically inheriting attributes based on a map of selectors to attributes r=paolo This allows elements to skip explicitly declaring `observedAttributes` and then imperatively calling `inheritAttribute` on the appropriate child nodes. For simple cases this means less boilerplate and moving this logic into the base class. This is an opt-in feature, so more complex cases can continue to manually implement inheriting behavior as before. Differential Revision: https://phabricator.services.mozilla.com/D19702
browser/components/search/content/searchbar.js
toolkit/content/customElements.js
toolkit/content/tests/chrome/test_custom_element_base.xul
toolkit/content/widgets/autocomplete-richlistitem.js
toolkit/content/widgets/menu.js
toolkit/content/widgets/menulist.js
toolkit/content/widgets/popupnotification.js
toolkit/content/widgets/tree.js
--- a/browser/components/search/content/searchbar.js
+++ b/browser/components/search/content/searchbar.js
@@ -4,56 +4,25 @@
 
 "use strict";
 
 /* globals XULCommandEvent */
 
 // This is loaded into chrome windows with the subscript loader. Wrap in
 // a block to prevent accidentally leaking globals onto `window`.
 {
-const inheritsMap = {
-  ".searchbar-textbox": ["disabled", "disableautocomplete", "searchengine", "src", "newlines"],
-  ".searchbar-search-button": ["addengines"],
-};
-
-function inheritAttribute(parent, child, attr) {
-  if (!parent.hasAttribute(attr)) {
-    child.removeAttribute(attr);
-  } else {
-    child.setAttribute(attr, parent.getAttribute(attr));
-  }
-}
-
 /**
  * Defines the search bar element.
  */
 class MozSearchbar extends MozXULElement {
-  static get observedAttributes() {
-    let unique = new Set();
-    for (let i in inheritsMap) {
-      inheritsMap[i].forEach(attr => unique.add(attr));
-    }
-    return Array.from(unique);
-  }
-
-  attributeChangedCallback() {
-    this.inheritAttributes();
-  }
-
-  inheritAttributes() {
-    if (!this.isConnected) {
-      return;
-    }
-
-    for (let sel in inheritsMap) {
-      let node = this.querySelector(sel);
-      for (let attr of inheritsMap[sel]) {
-        inheritAttribute(this, node, attr);
-      }
-    }
+  static get inheritedAttributes() {
+    return {
+      ".searchbar-textbox": "disabled,disableautocomplete,searchengine,src,newlines",
+      ".searchbar-search-button": "addengines",
+    };
   }
 
   constructor() {
     super();
     this.destroy = this.destroy.bind(this);
     this._setupEventListeners();
     let searchbar = this;
     this.observer = {
@@ -67,19 +36,19 @@ class MozSearchbar extends MozXULElement
           searchbar._textbox.popup.updateHeader();
           searchbar.updateDisplay();
         }
       },
       QueryInterface: ChromeUtils.generateQI([Ci.nsIObserver]),
     };
     this.content = MozXULElement.parseXULToFragment(`
       <stringbundle src="chrome://browser/locale/search.properties"></stringbundle>
-      <textbox class="searchbar-textbox" type="autocomplete" inputtype="search" placeholder="&searchInput.placeholder;" flex="1" autocompletepopup="PopupSearchAutoComplete" autocompletesearch="search-autocomplete" autocompletesearchparam="searchbar-history" maxrows="10" completeselectedindex="true" minresultsforpopup="0" inherits="disabled,disableautocomplete,searchengine,src,newlines">
+      <textbox class="searchbar-textbox" type="autocomplete" inputtype="search" placeholder="&searchInput.placeholder;" flex="1" autocompletepopup="PopupSearchAutoComplete" autocompletesearch="search-autocomplete" autocompletesearchparam="searchbar-history" maxrows="10" completeselectedindex="true" minresultsforpopup="0">
         <box>
-          <hbox class="searchbar-search-button" inherits="addengines" tooltiptext="&searchIcon.tooltip;">
+          <hbox class="searchbar-search-button" tooltiptext="&searchIcon.tooltip;">
             <image class="searchbar-search-icon"></image>
             <image class="searchbar-search-icon-overlay"></image>
           </hbox>
         </box>
         <hbox class="search-go-container">
           <image class="search-go-button urlbar-icon" hidden="true" onclick="handleSearchCommand(event);" tooltiptext="&contentSearchSubmit.tooltip;"></image>
         </hbox>
       </textbox>
@@ -88,17 +57,17 @@ class MozSearchbar extends MozXULElement
 
   connectedCallback() {
     // Don't initialize if this isn't going to be visible
     if (this.closest("#BrowserToolbarPalette")) {
       return;
     }
 
     this.appendChild(document.importNode(this.content, true));
-    this.inheritAttributes();
+    this.initializeAttributeInheritance();
     window.addEventListener("unload", this.destroy);
     this._ignoreFocus = false;
 
     this._clickClosedPopup = false;
 
     this._stringBundle = this.querySelector("stringbundle");
 
     this._textboxInitialized = false;
--- a/toolkit/content/customElements.js
+++ b/toolkit/content/customElements.js
@@ -43,18 +43,129 @@ window.addEventListener("DOMContentLoade
 
 const gXULDOMParser = new DOMParser();
 gXULDOMParser.forceEnableXULXBL();
 
 const MozElements = {};
 
 const MozElementMixin = Base => class MozElement extends Base {
   /*
+   * A declarative way to wire up attribute inheritance and automatically generate
+   * the `observedAttributes` getter.  For example, if you returned:
+   *    {
+   *      ".foo": "bar,baz=bat"
+   *    }
+   *
+   * Then the base class will automatically return ["bar", "bat"] from `observedAttributes`,
+   * and set up an `attributeChangedCallback` to pass those attributes down onto an element
+   * matching the ".foo" selector.
+   *
+   * See the `inheritAttribute` function for more details on the attribute string format.
+   *
+   * @return {Object<string selector, string attributes>}
+   */
+  static get inheritedAttributes() {
+    return null;
+  }
+
+  /*
+   * Generate this array based on `inheritedAttributes`, if any. A class is free to override
+   * this if it needs to do something more complex or wants to opt out of this behavior.
+   */
+  static get observedAttributes() {
+    let {inheritedAttributes} = this;
+    if (!inheritedAttributes) {
+      return [];
+    }
+
+    let allAttributes = new Set();
+    for (let sel in inheritedAttributes) {
+      for (let attrName of inheritedAttributes[sel].split(",")) {
+        allAttributes.add(attrName.split("=").pop());
+      }
+    }
+    return [...allAttributes];
+  }
+
+  /*
+   * Provide default lifecycle callback for attribute changes that will inherit attributes
+   * based on the static `inheritedAttributes` Object. This can be overridden by callers.
+   */
+  attributeChangedCallback(name, oldValue, newValue) {
+    if (!this.isConnectedAndReady || oldValue === newValue || !this.inheritedAttributesCache) {
+      return;
+    }
+
+    this.inheritAttributes();
+  }
+
+  /*
+  * After setting content, calling this will cache the elements from selectors in the
+  * static `inheritedAttributes` Object. It'll also do an initial call to `this.inheritAttributes()`,
+  * so in the simple case, this is the only function you need to call.
+  *
+  * This should be called any time the children that are inheriting attributes changes. For instance,
+  * it's common in a connectedCallback to do something like:
+  *
+  *   this.textContent = "";
+  *   this.append(MozXULElement.parseXULToFragment(`<label />`))
+  *   this.initializeAttributeInheritance();
+  *
+  */
+  initializeAttributeInheritance() {
+    let {inheritedAttributes} = this.constructor;
+    if (!inheritedAttributes) {
+      return;
+    }
+    this._inheritedAttributesValuesCache = null;
+    this.inheritedAttributesCache = new Map();
+    for (let selector in inheritedAttributes) {
+      let el = this.querySelector(selector);
+      // Skip unmatched selectors in case an element omits some elements in certain cases:
+      if (!el) {
+        continue;
+      }
+      if (this.inheritedAttributesCache.has(el)) {
+        console.error(`Error: duplicate element encountered with ${selector}`);
+      }
+
+      this.inheritedAttributesCache.set(el, inheritedAttributes[selector]);
+    }
+    this.inheritAttributes();
+  }
+
+  /*
+   * Loop through the static `inheritedAttributes` Map and inherit attributes to child elements.
+   *
+   * This usually won't need to be called directly - `this.initializeAttributeInheritance()` and
+   * `this.attributeChangedCallback` will call it for you when appropriate.
+   */
+  inheritAttributes() {
+    let {inheritedAttributes} = this.constructor;
+    if (!inheritedAttributes) {
+      return;
+    }
+
+    if (!this.inheritedAttributesCache) {
+     console.error(`You must call this.initializeAttributeInheritance() for ${this.tagName}`);
+     return;
+    }
+
+    for (let [ el, attrs ] of this.inheritedAttributesCache.entries()) {
+      for (let attr of attrs.split(",")) {
+        this.inheritAttribute(el, attr);
+      }
+    }
+  }
+
+  /*
    * Implements attribute inheritance by a child element. Uses XBL @inherit
-   * syntax of |to=from|.
+   * syntax of |to=from|. This can be used directly, but for simple cases
+   * you should use the inheritedAttributes getter and let the base class
+   * handle this for you.
    *
    * @param {element} child
    *        A child element that inherits an attribute.
    * @param {string} attr
    *        An attribute to inherit. Optionally in the form of |to=from|, where
    *        |to| is an attribute defined on custom element, whose value will be
    *        inherited to |from| attribute, defined a child element. Note |from| may
    *        take a special value of "text" to propogate attribute value as
@@ -69,23 +180,23 @@ const MozElementMixin = Base => class Mo
       attrNewName = split[0];
     }
     let hasAttr = this.hasAttribute(attrName);
     let attrValue = this.getAttribute(attrName);
 
     // If our attribute hasn't changed since we last inherited, we don't want to
     // propagate it down to the child. This prevents overriding an attribute that's
     // been changed on the child (for instance, [checked]).
-    if (!this._inheritedAttributesMap) {
-      this._inheritedAttributesMap = new WeakMap();
+    if (!this._inheritedAttributesValuesCache) {
+      this._inheritedAttributesValuesCache = new WeakMap();
     }
-    if (!this._inheritedAttributesMap.has(child)) {
-      this._inheritedAttributesMap.set(child, {});
+    if (!this._inheritedAttributesValuesCache.has(child)) {
+      this._inheritedAttributesValuesCache.set(child, {});
     }
-    let lastInheritedAttributes = this._inheritedAttributesMap.get(child);
+    let lastInheritedAttributes = this._inheritedAttributesValuesCache.get(child);
 
     if ((hasAttr && attrValue === lastInheritedAttributes[attrName]) ||
         (!hasAttr && !lastInheritedAttributes.hasOwnProperty(attrName))) {
       // We got a request to inherit an unchanged attribute - bail.
       return;
     }
 
     // Store the value we're about to pass down to the child.
--- a/toolkit/content/tests/chrome/test_custom_element_base.xul
+++ b/toolkit/content/tests/chrome/test_custom_element_base.xul
@@ -11,32 +11,33 @@
 
   <!-- test results are displayed in the html:body -->
   <body xmlns="http://www.w3.org/1999/xhtml" style="height: 300px; overflow: auto;"/>
 
   <button id="one"/>
   <simpleelement id="two" style="-moz-user-focus: normal;"/>
   <simpleelement id="three" disabled="true" style="-moz-user-focus: normal;"/>
   <button id="four"/>
-  <inherited-element foo="fuagra"></inherited-element>
+  <inherited-element-declarative foo="fuagra"></inherited-element-declarative>
+  <inherited-element-imperative foo="fuagra"></inherited-element-imperative>
 
 
   <!-- test code goes here -->
   <script type="application/javascript"><![CDATA[
 
   SimpleTest.waitForExplicitFinish();
 
   async function runTests() {
     ok(MozXULElement, "MozXULElement defined on the window");
     testMixin();
     testBaseControl();
     testBaseControlMixin();
     testBaseText();
     testParseXULToFragment();
-    testInherits();
+    testInheritAttributes();
     await testCustomInterface();
 
     let htmlWin = await new Promise(resolve => {
       let htmlIframe = document.createElement("iframe");
       htmlIframe.src = "file_empty.xhtml";
       htmlIframe.onload = () => resolve(htmlIframe.contentWindow);
       document.documentElement.appendChild(htmlIframe);
     });
@@ -83,18 +84,36 @@
     let deck = document.documentElement.lastChild;
     ok(deck instanceof MozXULElement, "instance of MozXULElement");
     ok(deck instanceof XULElement, "instance of XULElement");
     is(deck.id, "foo", "attribute set");
     is(deck.selectedIndex, "0", "Custom Element is property attached");
     deck.remove();
   }
 
-  function testInherits() {
-    class InheritsElement extends MozXULElement {
+  function testInheritAttributes() {
+    class InheritsElementDeclarative extends MozXULElement {
+      static get inheritedAttributes() {
+        return {
+          "label": "text=label,foo,boo,bardo=bar",
+          "unmatched": "foo", // Make sure we don't throw on unmatched selectors
+        };
+      }
+
+      connectedCallback() {
+        this.append(MozXULElement.parseXULToFragment(`<label />`));
+        this.label = this.querySelector("label");
+        this.initializeAttributeInheritance();
+      }
+    }
+    customElements.define("inherited-element-declarative", InheritsElementDeclarative);
+    let declarativeEl = document.querySelector("inherited-element-declarative");
+    ok(declarativeEl, "declarative inheritance element exists");
+
+    class InheritsElementImperative extends MozXULElement {
       static get observedAttributes() {
         return [ "label", "foo", "boo", "bar" ];
       }
 
       attributeChangedCallback(name, oldValue, newValue) {
         if (this.label && oldValue != newValue) {
           this.inherit();
         }
@@ -108,59 +127,59 @@
 
       connectedCallback() {
         this.append(MozXULElement.parseXULToFragment(`<label />`));
         this.label = this.querySelector("label");
         this.inherit();
       }
     }
 
-    customElements.define("inherited-element", InheritsElement);
-    let el = document.querySelector("inherited-element");
-    ok(el, "element exists");
-
-    is(el.label.getAttribute("foo"), "fuagra", "predefined attribute @foo");
-    ok(!el.label.hasAttribute("boo"), "predefined attribute @boo");
-    ok(!el.label.hasAttribute("bardo"), "predefined attribute @bardo");
-    ok(!el.label.textContent, "predefined attribute @label");
+    customElements.define("inherited-element-imperative", InheritsElementImperative);
+    let imperativeEl = document.querySelector("inherited-element-imperative");
+    ok(imperativeEl, "imperative inheritance element exists");
 
-    el.setAttribute("boo", "boo-test");
-    is(el.label.getAttribute("boo"), "boo-test",
-       "attribute inheritance: boo");
+    for (let el of [declarativeEl, imperativeEl]) {
+      info(`Running checks for ${el.tagName}`);
+      is(el.label.getAttribute("foo"), "fuagra", "predefined attribute @foo");
+      ok(!el.label.hasAttribute("boo"), "predefined attribute @boo");
+      ok(!el.label.hasAttribute("bardo"), "predefined attribute @bardo");
+      ok(!el.label.textContent, "predefined attribute @label");
 
-    el.setAttribute("label", "label-test");
-    is(el.label.textContent, "label-test",
-       "attribute inheritance: text=label attribute change");
+      el.setAttribute("boo", "boo-test");
+      is(el.label.getAttribute("boo"), "boo-test",
+        "attribute inheritance: boo");
 
-    el.setAttribute("bar", "bar-test");
-    is(el.label.getAttribute("bardo"), "bar-test",
-       "attribute inheritance: `=` mapping");
+      el.setAttribute("label", "label-test");
+      is(el.label.textContent, "label-test",
+        "attribute inheritance: text=label attribute change");
 
-    el.label.setAttribute("bardo", "changed-from-child");
-    el.inherit();
-    is(el.label.getAttribute("bardo"), "changed-from-child",
-       "attribute inheritance: doesn't apply when host attr hasn't changed and child attr was changed");
+      el.setAttribute("bar", "bar-test");
+      is(el.label.getAttribute("bardo"), "bar-test",
+        "attribute inheritance: `=` mapping");
 
-    el.label.removeAttribute("bardo");
-    el.inherit();
-    ok(!el.label.hasAttribute("bardo"),
-       "attribute inheritance: doesn't apply when host attr hasn't changed and child attr was removed");
+      el.label.setAttribute("bardo", "changed-from-child");
+      is(el.label.getAttribute("bardo"), "changed-from-child",
+        "attribute inheritance: doesn't apply when host attr hasn't changed and child attr was changed");
+
+      el.label.removeAttribute("bardo");
+      ok(!el.label.hasAttribute("bardo"),
+        "attribute inheritance: doesn't apply when host attr hasn't changed and child attr was removed");
 
-    el.setAttribute("bar", "changed-from-host");
-    is(el.label.getAttribute("bardo"), "changed-from-host",
-       "attribute inheritance: does apply when host attr has changed and child attr was changed");
+      el.setAttribute("bar", "changed-from-host");
+      is(el.label.getAttribute("bardo"), "changed-from-host",
+        "attribute inheritance: does apply when host attr has changed and child attr was changed");
 
-    el.removeAttribute("bar");
-    ok(!el.label.hasAttribute("bardo"),
-       "attribute inheritance: does apply when host attr has been removed");
+      el.removeAttribute("bar");
+      ok(!el.label.hasAttribute("bardo"),
+        "attribute inheritance: does apply when host attr has been removed");
 
-    el.setAttribute("bar", "changed-from-host-2");
-    is(el.label.getAttribute("bardo"), "changed-from-host-2",
-       "attribute inheritance: does apply when host attr has changed after being removed");
-
+      el.setAttribute("bar", "changed-from-host-2");
+      is(el.label.getAttribute("bardo"), "changed-from-host-2",
+        "attribute inheritance: does apply when host attr has changed after being removed");
+    }
   }
 
   async function testCustomInterface() {
     class SimpleElement extends MozXULElement {
       get disabled() {
         return this.getAttribute("disabled") == "true";
       }
 
--- a/toolkit/content/widgets/autocomplete-richlistitem.js
+++ b/toolkit/content/widgets/autocomplete-richlistitem.js
@@ -56,66 +56,38 @@ MozElements.MozAutocompleteRichlistitem 
 
   connectedCallback() {
     if (this.delayConnectedCallback()) {
       return;
     }
 
     this.textContent = "";
     this.appendChild(MozXULElement.parseXULToFragment(this._markup));
+    this.initializeAttributeInheritance();
 
     this._boundaryCutoff = null;
     this._inOverflow = false;
 
-    this._updateAttributes();
     this._adjustAcItem();
   }
 
-  static get observedAttributes() {
-    return [
-      "actiontype",
-      "current",
-      "selected",
-      "image",
-      "type",
-    ];
-  }
-
-  get inheritedAttributeMap() {
-    if (!this.__inheritedAttributeMap) {
-      this.__inheritedAttributeMap = new Map([
-        [ this.querySelector(".ac-type-icon"), [ "selected", "current", "type" ] ],
-        [ this.querySelector(".ac-site-icon"), [ "src=image", "selected", "type" ] ],
-        [ this.querySelector(".ac-title"), [ "selected" ] ],
-        [ this.querySelector(".ac-title-text"), [ "selected" ] ],
-        [ this.querySelector(".ac-tags"), [ "selected" ] ],
-        [ this.querySelector(".ac-tags-text"), [ "selected" ] ],
-        [ this.querySelector(".ac-separator"), [ "selected", "actiontype", "type" ] ],
-        [ this.querySelector(".ac-url"), [ "selected", "actiontype" ] ],
-        [ this.querySelector(".ac-url-text"), [ "selected" ] ],
-        [ this.querySelector(".ac-action"), [ "selected", "actiontype" ] ],
-        [ this.querySelector(".ac-action-text"), [ "selected" ] ],
-      ]);
-    }
-    return this.__inheritedAttributeMap;
-  }
-
-  attributeChangedCallback(name, oldValue, newValue) {
-    if (this.isConnectedAndReady && oldValue != newValue &&
-        this.constructor.observedAttributes.includes(name)) {
-      this._updateAttributes();
-    }
-  }
-
-  _updateAttributes() {
-    for (let [ el, attrs ] of this.inheritedAttributeMap.entries()) {
-      for (let attr of attrs) {
-        this.inheritAttribute(el, attr);
-      }
-    }
+  static get inheritedAttributes() {
+    return {
+      ".ac-type-icon": "selected,current,type",
+      ".ac-site-icon": "src=image,selected,type",
+      ".ac-title": "selected",
+      ".ac-title-text": "selected",
+      ".ac-tags": "selected",
+      ".ac-tags-text": "selected",
+      ".ac-separator": "selected,actiontype,type",
+      ".ac-url": "selected,actiontype",
+      ".ac-url-text": "selected",
+      ".ac-action": "selected,actiontype",
+      ".ac-action-text": "selected",
+    };
   }
 
   get _markup() {
     return `
       <image class="ac-type-icon"></image>
       <image class="ac-site-icon"></image>
       <hbox class="ac-title" align="center">
         <description class="ac-text-overflow-container">
@@ -937,41 +909,28 @@ class MozAutocompleteRichlistitemInsecur
     super.connectedCallback();
 
     // Unlike other autocomplete items, the height of the insecure warning
     // increases by wrapping. So "forceHandleUnderflow" is for container to
     // recalculate an item's height and width.
     this.classList.add("forceHandleUnderflow");
   }
 
-  static get observedAttributes() {
-    return [
-      "actiontype",
-      "current",
-      "selected",
-      "image",
-      "type",
-    ];
-  }
-
-  get inheritedAttributeMap() {
-    if (!this.__inheritedAttributeMap) {
-      this.__inheritedAttributeMap = new Map([
-        [ this.querySelector(".ac-type-icon"), [ "selected", "current", "type" ] ],
-        [ this.querySelector(".ac-site-icon"), [ "src=image", "selected", "type" ] ],
-        [ this.querySelector(".ac-title-text"), [ "selected" ] ],
-        [ this.querySelector(".ac-tags-text"), [ "selected" ] ],
-        [ this.querySelector(".ac-separator"), [ "selected", "actiontype", "type" ] ],
-        [ this.querySelector(".ac-url"), [ "selected", "actiontype" ] ],
-        [ this.querySelector(".ac-url-text"), [ "selected" ] ],
-        [ this.querySelector(".ac-action"), [ "selected", "actiontype" ] ],
-        [ this.querySelector(".ac-action-text"), [ "selected" ] ],
-      ]);
-    }
-    return this.__inheritedAttributeMap;
+  static get inheritedAttributes() {
+    return {
+      ".ac-type-icon": "selected,current,type",
+      ".ac-site-icon": "src=image,selected,type",
+      ".ac-title-text": "selected",
+      ".ac-tags-text": "selected",
+      ".ac-separator": "selected,actiontype,type",
+      ".ac-url": "selected,actiontype",
+      ".ac-url-text": "selected",
+      ".ac-action": "selected,actiontype",
+      ".ac-action-text": "selected",
+    };
   }
 
   get _markup() {
     return `
       <image class="ac-type-icon"></image>
       <image class="ac-site-icon"></image>
       <vbox class="ac-title" align="left">
         <description class="ac-text-overflow-container">
--- a/toolkit/content/widgets/menu.js
+++ b/toolkit/content/widgets/menu.js
@@ -106,28 +106,23 @@ class MozMenuBase extends MozMenuItemBas
   }
 }
 
 MozXULElement.implementCustomInterface(MozMenuBase, [Ci.nsIDOMXULContainerElement]);
 
 // The <menucaption> element is used for rendering <html:optgroup> inside of <html:select>,
 // See SelectParentHelper.jsm.
 class MozMenuCaption extends MozMenuBase {
-  static get observedAttributes() {
-    return [
-      "selected",
-      "disabled",
-      "checked",
-      "image",
-      "validate",
-      "src",
-      "label",
-      "crop",
-      "highlightable",
-    ];
+  static get inheritedAttributes() {
+    return {
+      ".menu-iconic-left": "selected,disabled,checked",
+      ".menu-iconic-icon": "src=image,validate,src",
+      ".menu-iconic-text": "value=label,crop,highlightable",
+      ".menu-iconic-highlightable-text": "text=label,crop,highlightable",
+    };
   }
 
   _updateAttributes() {
     if (!this._inheritedAttributeMap) {
       return;
     }
 
     for (let [ el, attrs ] of this._inheritedAttributeMap.entries()) {
@@ -143,24 +138,20 @@ class MozMenuCaption extends MozMenuBase
     }
 
     this._updateAttributes();
   }
 
   connectedCallback() {
     this.textContent = "";
     this.appendChild(MozXULElement.parseXULToFragment(`
-      <hbox class="menu-iconic-left" align="center" pack="center" inherits="selected,disabled,checked" role="none">
-        <image class="menu-iconic-icon" inherits="src=image,validate,src" role="none"></image>
+      <hbox class="menu-iconic-left" align="center" pack="center" role="none">
+        <image class="menu-iconic-icon" role="none"></image>
       </hbox>
-      <label class="menu-iconic-text" flex="1" inherits="value=label,crop,highlightable" crop="right" role="none"></label>
-      <label class="menu-iconic-highlightable-text" inherits="text=label,crop,highlightable" crop="right" role="none"></label>
+      <label class="menu-iconic-text" flex="1" crop="right" role="none"></label>
+      <label class="menu-iconic-highlightable-text" crop="right" role="none"></label>
     `));
-    this._inheritedAttributeMap = new Map();
-    for (let el of this.querySelectorAll("[inherits]")) {
-      this._inheritedAttributeMap.set(el, el.getAttribute("inherits").split(","));
-    }
-    this._updateAttributes();
+    this.initializeAttributeInheritance();
   }
 }
 
 customElements.define("menucaption", MozMenuCaption);
 }
--- a/toolkit/content/widgets/menulist.js
+++ b/toolkit/content/widgets/menulist.js
@@ -51,29 +51,37 @@ class MozMenuList extends MenuBaseContro
         if (this.handleKeyPress(event)) {
           this.activeChild.doCommand();
           event.preventDefault();
         }
       }
     }, { mozSystemGroup: true });
   }
 
+  static get inheritedAttributes() {
+    return {
+      ".menulist-icon": "src=image",
+      ".menulist-label": "value=label,crop,accesskey,highlightable",
+      ".menulist-highlightable-label": "text=label,crop,accesskey,highlightable",
+      ".menulist-dropmarker": "disabled,open",
+    };
+  }
+
   connectedCallback() {
     if (this.delayConnectedCallback()) {
       return;
     }
 
     if (this.getAttribute("popuponly") != "true") {
       this.prepend(MozMenuList.fragment.cloneNode(true));
       this._labelBox = this.children[0];
       this._dropmarker = this.children[1];
+      this.initializeAttributeInheritance();
     }
 
-    this._updateAttributes();
-
     this.mSelectedInternal = null;
     this.mAttributeObserver = null;
     this.setInitialSelection();
   }
 
   static get fragment() {
     // Accessibility information of these nodes will be
     // presented on XULComboboxAccessible generated from <menulist>;
@@ -356,50 +364,15 @@ class MozMenuList extends MenuBaseContro
 
     if (this._labelBox) {
       this._labelBox.remove();
       this._dropmarker.remove();
       this._labelBox = null;
       this._dropmarker = null;
     }
   }
-
-  static get observedAttributes() {
-    return ["label", "crop", "accesskey", "highlightable", "image", "disabled",
-            "open"];
-  }
-
-  attributeChangedCallback() {
-    if (this.isConnectedAndReady) {
-      this._updateAttributes();
-    }
-  }
-
-  _updateAttributes() {
-    if (!this._labelBox) {
-      return;
-    }
-
-    let icon = this._labelBox.querySelector(".menulist-icon");
-    this.inheritAttribute(icon, "src=image");
-
-    let label = this._labelBox.querySelector(".menulist-label");
-    this.inheritAttribute(label, "value=label");
-    this.inheritAttribute(label, "crop");
-    this.inheritAttribute(label, "accesskey");
-    this.inheritAttribute(label, "highlightable");
-
-    let highlightableLabel = this._labelBox.querySelector(".menulist-highlightable-label");
-    this.inheritAttribute(highlightableLabel, "text=label");
-    this.inheritAttribute(highlightableLabel, "crop");
-    this.inheritAttribute(highlightableLabel, "accesskey");
-    this.inheritAttribute(highlightableLabel, "highlightable");
-
-    this.inheritAttribute(this._dropmarker, "disabled");
-    this.inheritAttribute(this._dropmarker, "open");
-  }
 }
 
 MenuBaseControl.implementCustomInterface(MozMenuList, [Ci.nsIDOMXULMenuListElement,
                                                        Ci.nsIDOMXULSelectControlElement]);
 
 customElements.define("menulist", MozMenuList);
 }
--- a/toolkit/content/widgets/popupnotification.js
+++ b/toolkit/content/widgets/popupnotification.js
@@ -3,73 +3,44 @@
   * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 // This is loaded into all XUL windows. Wrap in a block to prevent
 // leaking to window scope.
 {
 class MozPopupNotification extends MozXULElement {
-  static get observedAttributes() {
-    return [
-      "buttonaccesskey",
-      "buttoncommand",
-      "buttonhighlight",
-      "buttonlabel",
-      "closebuttoncommand",
-      "closebuttonhidden",
-      "dropmarkerhidden",
-      "dropmarkerpopupshown",
-      "endlabel",
-      "icon",
-      "iconclass",
-      "label",
-      "learnmoreclick",
-      "learnmoreurl",
-      "mainactiondisabled",
-      "menucommand",
-      "name",
-      "origin",
-      "origin",
-      "popupid",
-      "secondarybuttonaccesskey",
-      "secondarybuttoncommand",
-      "secondarybuttonhidden",
-      "secondarybuttonlabel",
-      "secondendlabel",
-      "secondname",
-      "warninghidden",
-      "warninglabel",
-    ];
-  }
-
-  _updateAttributes() {
-    for (let [ el, attrs ] of this._inheritedAttributeMap.entries()) {
-      for (let attr of attrs) {
-        this.inheritAttribute(el, attr);
-      }
-    }
-  }
-
-  get _inheritedAttributeMap() {
-    if (!this.__inheritedAttributeMap) {
-      this.__inheritedAttributeMap = new Map();
-      for (let el of this.querySelectorAll("[inherits]")) {
-        this.__inheritedAttributeMap.set(el, el.getAttribute("inherits").split(","));
-      }
-    }
-    return this.__inheritedAttributeMap;
+  static get inheritedAttributes() {
+    return {
+      ".popup-notification-icon": "popupid,src=icon,class=iconclass",
+      ".popup-notification-origin": "value=origin,tooltiptext=origin",
+      ".popup-notification-description": "popupid",
+      ".popup-notification-description > span:first-of-type": "text=label,popupid",
+      ".popup-notification-description > b:first-of-type": "text=name,popupid",
+      ".popup-notification-description > span:nth-of-type(2)": "text=endlabel,popupid",
+      ".popup-notification-description > b:last-of-type": "text=secondname,popupid",
+      ".popup-notification-description > span:last-of-type": "secondendlabel,popupid",
+      ".popup-notification-closebutton": "oncommand=closebuttoncommand,hidden=closebuttonhidden",
+      ".popup-notification-learnmore-link": "onclick=learnmoreclick,href=learnmoreurl",
+      ".popup-notification-warning": "hidden=warninghidden,text=warninglabel",
+      ".popup-notification-button-container > .popup-notification-secondary-button":
+          "oncommand=secondarybuttoncommand,label=secondarybuttonlabel,accesskey=secondarybuttonaccesskey,hidden=secondarybuttonhidden",
+      ".popup-notification-button-container > toolbarseparator": "hidden=dropmarkerhidden",
+      ".popup-notification-dropmarker": "onpopupshown=dropmarkerpopupshown,hidden=dropmarkerhidden",
+      ".popup-notification-dropmarker > menupopup": "oncommand=menucommand",
+      ".popup-notification-primary-button": "oncommand=buttoncommand,label=buttonlabel,accesskey=buttonaccesskey,default=buttonhighlight,disabled=mainactiondisabled",
+    };
   }
 
   attributeChangedCallback(name, oldValue, newValue) {
-    if (!this._hasSlotted || oldValue === newValue) {
+    if (!this._hasSlotted) {
       return;
     }
 
-    this._updateAttributes();
+    super.attributeChangedCallback(name, oldValue, newValue);
   }
 
   show() {
     this.slotContents();
 
     if (this.checkboxState) {
       this.checkbox.checked = this.checkboxState.checked;
       this.checkbox.setAttribute("label", this.checkboxState.label);
@@ -84,43 +55,42 @@ class MozPopupNotification extends MozXU
   slotContents() {
     if (this._hasSlotted) {
       return;
     }
     this._hasSlotted = true;
     this.appendChild(MozXULElement.parseXULToFragment(`
       <hbox class="popup-notification-header-container"></hbox>
       <hbox align="start" class="popup-notification-body-container">
-        <image class="popup-notification-icon"
-               inherits="popupid,src=icon,class=iconclass"/>
+        <image class="popup-notification-icon"/>
         <vbox flex="1" pack="start" class="popup-notification-body">
           <hbox align="start">
             <vbox flex="1">
-              <label class="popup-notification-origin header" inherits="value=origin,tooltiptext=origin" crop="center"></label>
+              <label class="popup-notification-origin header" crop="center"></label>
               <!-- These need to be on the same line to avoid creating
                   whitespace between them (whitespace is added in the
                   localization file, if necessary). -->
-              <description class="popup-notification-description" inherits="popupid"><html:span inherits="text=label,popupid"></html:span><html:b inherits="text=name,popupid"></html:b><html:span inherits="text=endlabel,popupid"></html:span><html:b inherits="text=secondname,popupid"></html:b><html:span inherits="text=secondendlabel,popupid"></html:span></description>
+              <description class="popup-notification-description"><html:span></html:span><html:b></html:b><html:span></html:span><html:b></html:b><html:span></html:span></description>
             </vbox>
-            <toolbarbutton class="messageCloseButton close-icon popup-notification-closebutton tabbable" inherits="oncommand=closebuttoncommand,hidden=closebuttonhidden" tooltiptext="&closeNotification.tooltip;"></toolbarbutton>
+            <toolbarbutton class="messageCloseButton close-icon popup-notification-closebutton tabbable" tooltiptext="&closeNotification.tooltip;"></toolbarbutton>
           </hbox>
-          <label class="text-link popup-notification-learnmore-link" inherits="onclick=learnmoreclick,href=learnmoreurl">&learnMore;</label>
+          <label class="text-link popup-notification-learnmore-link">&learnMore;</label>
           <checkbox class="popup-notification-checkbox" oncommand="PopupNotifications._onCheckboxCommand(event)"></checkbox>
-          <description class="popup-notification-warning" inherits="hidden=warninghidden,text=warninglabel"></description>
+          <description class="popup-notification-warning"></description>
         </vbox>
       </hbox>
       <hbox class="popup-notification-footer-container"></hbox>
       <hbox class="popup-notification-button-container panel-footer">
-        <button class="popup-notification-button popup-notification-secondary-button" inherits="oncommand=secondarybuttoncommand,label=secondarybuttonlabel,accesskey=secondarybuttonaccesskey,hidden=secondarybuttonhidden"></button>
-        <toolbarseparator inherits="hidden=dropmarkerhidden"></toolbarseparator>
-        <button type="menu" class="popup-notification-button popup-notification-dropmarker" aria-label="&moreActionsButton.accessibleLabel;" inherits="onpopupshown=dropmarkerpopupshown,hidden=dropmarkerhidden">
-          <menupopup position="after_end" aria-label="&moreActionsButton.accessibleLabel;" inherits="oncommand=menucommand">
+        <button class="popup-notification-button popup-notification-secondary-button"></button>
+        <toolbarseparator></toolbarseparator>
+        <button type="menu" class="popup-notification-button popup-notification-dropmarker" aria-label="&moreActionsButton.accessibleLabel;">
+          <menupopup position="after_end" aria-label="&moreActionsButton.accessibleLabel;">
           </menupopup>
         </button>
-        <button class="popup-notification-button popup-notification-primary-button" label="&defaultButton.label;" accesskey="&defaultButton.accesskey;" inherits="oncommand=buttoncommand,label=buttonlabel,accesskey=buttonaccesskey,default=buttonhighlight,disabled=mainactiondisabled"></button>
+        <button class="popup-notification-button popup-notification-primary-button" label="&defaultButton.label;" accesskey="&defaultButton.accesskey;"></button>
       </hbox>
     `, ["chrome://global/locale/notification.dtd"]));
 
     this.button = this.querySelector(".popup-notification-primary-button");
     this.secondaryButton =  this.querySelector(".popup-notification-secondary-button");
     this.checkbox = this.querySelector(".popup-notification-checkbox");
     this.closebutton = this.querySelector(".popup-notification-closebutton");
     this.menubutton = this.querySelector(".popup-notification-dropmarker");
@@ -135,17 +105,17 @@ class MozPopupNotification extends MozXU
     if (popupnotificationheader) {
       this.querySelector(".popup-notification-header-container").append(popupnotificationheader);
     }
 
     for (let popupnotificationcontent of this.querySelectorAll("popupnotificationcontent")) {
       this.appendNotificationContent(popupnotificationcontent);
     }
 
-    this._updateAttributes();
+    this.initializeAttributeInheritance();
   }
 
   appendNotificationContent(el) {
     let nextSibling = this.querySelector(".popup-notification-body > .popup-notification-learnmore-link");
     nextSibling.before(el);
   }
 }
 
--- a/toolkit/content/widgets/tree.js
+++ b/toolkit/content/widgets/tree.js
@@ -239,23 +239,21 @@
       var hidden = !tree.enableColumnDrag;
       aPopup.querySelectorAll(":not([colindex])").forEach((e) => { e.hidden = hidden; });
     }
   }
 
   customElements.define("treecolpicker", MozTreecolPicker);
 
   class MozTreecol extends MozElements.BaseControl {
-    static get observedAttributes() {
-      return [
-        "label",
-        "sortdirection",
-        "hideheader",
-        "crop",
-      ];
+    static get inheritedAttributes() {
+      return {
+        ".treecol-sortdirection": "sortdirection,hidden=hideheader",
+        ".treecol-text": "value=label,crop",
+      };
     }
 
     get content() {
       return MozXULElement.parseXULToFragment(`
         <label class="treecol-text" flex="1" crop="right"></label>
         <image class="treecol-sortdirection"></image>
     `);
     }
@@ -311,38 +309,17 @@
         return;
       }
       if (!this.isRunningDelayedConnectedCallback) {
         this.markTreeDirty();
       }
 
       this.textContent = "";
       this.appendChild(this.content);
-
-      this._updateAttributes();
-    }
-
-    attributeChangedCallback() {
-      if (this.isConnectedAndReady) {
-        this._updateAttributes();
-      }
-    }
-
-    _updateAttributes() {
-      let image = this.querySelector(".treecol-sortdirection");
-      let label = this.querySelector(".treecol-text");
-
-      this.inheritAttribute(image, "sortdirection");
-      this.inheritAttribute(image, "hidden=hideheader");
-      this.inheritAttribute(label, "value=label");
-
-      // Don't remove the attribute on the child if it's los on the host.
-      if (this.hasAttribute("crop")) {
-        this.inheritAttribute(label, "crop");
-      }
+      this.initializeAttributeInheritance();
     }
 
     set ordinal(val) {
       this.setAttribute("ordinal", val);
       return val;
     }
 
     get ordinal() {
@@ -484,30 +461,34 @@
       aEvent.stopPropagation();
       aEvent.preventDefault();
     }
   }
 
   customElements.define("treecol", MozTreecol);
 
   class MozTreecols extends MozElements.BaseControl {
+    static get inheritedAttributes() {
+      return {
+        "treecolpicker": "tooltiptext=pickertooltiptext",
+      };
+    }
+
     connectedCallback() {
       if (this.delayConnectedCallback()) {
         return;
       }
 
       if (!this.querySelector("treecolpicker")) {
         this.appendChild(MozXULElement.parseXULToFragment(`
           <treecolpicker class="treecol-image" fixed="true"></treecolpicker>
         `));
+        this.initializeAttributeInheritance();
       }
 
-      let treecolpicker = this.querySelector("treecolpicker");
-      this.inheritAttribute(treecolpicker, "tooltiptext=pickertooltiptext");
-
       // Set resizeafter="farthest" on the splitters if nothing else has been
       // specified.
       Array.forEach(this.getElementsByTagName("splitter"), function(splitter) {
         if (!splitter.hasAttribute("resizeafter"))
           splitter.setAttribute("resizeafter", "farthest");
       });
     }
   }