Bug 1363862 - Add Node.localize API as a fast-path for Fluent DOM localization. r=baku
authorZibi Braniecki <zbraniecki@mozilla.com>
Wed, 21 Feb 2018 14:07:53 -0800
changeset 406706 3ce625a51597
parent 406705 208c4c42f641
child 406707 c483f8c3bf07
push id33576
push userdluca@mozilla.com
push dateTue, 06 Mar 2018 18:34:13 +0000
treeherdermozilla-central@b498494cfac5 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersbaku
bugs1363862
milestone60.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 1363862 - Add Node.localize API as a fast-path for Fluent DOM localization. r=baku MozReview-Commit-ID: 6mj0q21Nbey
dom/base/nsGkAtomList.h
dom/base/nsINode.cpp
dom/base/nsINode.h
dom/base/test/chrome/chrome.ini
dom/base/test/chrome/test_node_localize.xul
dom/bindings/Bindings.conf
dom/webidl/L10nUtils.webidl
dom/webidl/Node.webidl
dom/webidl/moz.build
--- a/dom/base/nsGkAtomList.h
+++ b/dom/base/nsGkAtomList.h
@@ -295,16 +295,19 @@ GK_ATOM(count, "count")
 GK_ATOM(crop, "crop")
 GK_ATOM(crossorigin, "crossorigin")
 GK_ATOM(curpos, "curpos")
 GK_ATOM(current, "current")
 GK_ATOM(cutoutregion, "cutoutregion")
 GK_ATOM(cycler, "cycler")
 GK_ATOM(data, "data")
 GK_ATOM(datalist, "datalist")
+GK_ATOM(datal10nid, "data-l10n-id")
+GK_ATOM(datal10nargs, "data-l10n-args")
+GK_ATOM(datal10nattrs, "data-l10n-attrs")
 GK_ATOM(dataType, "data-type")
 GK_ATOM(dateTime, "date-time")
 GK_ATOM(datasources, "datasources")
 GK_ATOM(date, "date")
 GK_ATOM(datetime, "datetime")
 GK_ATOM(datetimebox, "datetimebox")
 GK_ATOM(dblclick, "dblclick")
 GK_ATOM(dd, "dd")
--- a/dom/base/nsINode.cpp
+++ b/dom/base/nsINode.cpp
@@ -25,16 +25,19 @@
 #include "mozilla/Telemetry.h"
 #include "mozilla/TextEditor.h"
 #include "mozilla/TimeStamp.h"
 #ifdef MOZ_OLD_STYLE
 #include "mozilla/css/StyleRule.h"
 #endif
 #include "mozilla/dom/Element.h"
 #include "mozilla/dom/Event.h"
+#include "mozilla/dom/L10nUtilsBinding.h"
+#include "mozilla/dom/Promise.h"
+#include "mozilla/dom/PromiseNativeHandler.h"
 #include "mozilla/dom/ShadowRoot.h"
 #include "nsAttrValueOrString.h"
 #include "nsBindingManager.h"
 #include "nsCCUncollectableMarker.h"
 #include "nsContentCreatorFunctions.h"
 #include "nsContentList.h"
 #include "nsContentUtils.h"
 #include "nsCycleCollectionParticipant.h"
@@ -3051,8 +3054,222 @@ nsINode::IsStyledByServo() const
 }
 #endif
 
 DocGroup*
 nsINode::GetDocGroup() const
 {
   return OwnerDoc()->GetDocGroup();
 }
+
+class LocalizationHandler : public PromiseNativeHandler
+{
+public:
+  LocalizationHandler() = default;
+
+  NS_DECL_CYCLE_COLLECTING_ISUPPORTS
+  NS_DECL_CYCLE_COLLECTION_CLASS(LocalizationHandler)
+
+  nsTArray<nsCOMPtr<Element>>& Elements() { return mElements; }
+
+  void SetReturnValuePromise(Promise* aReturnValuePromise)
+  {
+    mReturnValuePromise = aReturnValuePromise;
+  }
+
+  virtual void
+  ResolvedCallback(JSContext* aCx, JS::Handle<JS::Value> aValue) override
+  {
+    nsTArray<L10nValue> l10nData;
+    if (aValue.isObject()) {
+      JS::ForOfIterator iter(aCx);
+      if (!iter.init(aValue, JS::ForOfIterator::AllowNonIterable)) {
+        mReturnValuePromise->MaybeRejectWithUndefined();
+        return;
+      }
+      if (!iter.valueIsIterable()) {
+        mReturnValuePromise->MaybeRejectWithUndefined();
+        return;
+      }
+
+      JS::Rooted<JS::Value> temp(aCx);
+      while (true) {
+        bool done;
+        if (!iter.next(&temp, &done)) {
+          mReturnValuePromise->MaybeRejectWithUndefined();
+          return;
+        }
+
+        if (done) {
+          break;
+        }
+
+        L10nValue* slotPtr =
+          l10nData.AppendElement(mozilla::fallible);
+        if (!slotPtr) {
+          mReturnValuePromise->MaybeRejectWithUndefined();
+          return;
+        }
+
+        if (!slotPtr->Init(aCx, temp)) {
+          mReturnValuePromise->MaybeRejectWithUndefined();
+          return;
+        }
+      }
+    }
+
+    if (mElements.Length() != l10nData.Length()) {
+      mReturnValuePromise->MaybeRejectWithUndefined();
+      return;
+    }
+
+    JS::Rooted<JSObject*> untranslatedElements(aCx,
+      JS_NewArrayObject(aCx, mElements.Length()));
+    if (!untranslatedElements) {
+      mReturnValuePromise->MaybeRejectWithUndefined();
+      return;
+    }
+
+    ErrorResult rv;
+    for (size_t i = 0; i < l10nData.Length(); ++i) {
+      Element* elem = mElements[i];
+      nsString& content = l10nData[i].mValue;
+      if (!content.IsVoid()) {
+        elem->SetTextContent(content, rv);
+        if (NS_WARN_IF(rv.Failed())) {
+          mReturnValuePromise->MaybeRejectWithUndefined();
+          return;
+        }
+      }
+
+      Nullable<Sequence<AttributeNameValue>>& attributes =
+        l10nData[i].mAttrs;
+      if (!attributes.IsNull()) {
+        for (size_t j = 0; j < attributes.Value().Length(); ++j) {
+          // Use SetAttribute here to validate the attribute name!
+          elem->SetAttribute(attributes.Value()[j].mName,
+                             attributes.Value()[j].mValue,
+                             rv);
+          if (rv.Failed()) {
+            mReturnValuePromise->MaybeRejectWithUndefined();
+            return;
+          }
+        }
+      }
+
+      if (content.IsVoid() && attributes.IsNull()) {
+        JS::Rooted<JS::Value> wrappedElem(aCx);
+        if (!ToJSValue(aCx, elem, &wrappedElem)) {
+          mReturnValuePromise->MaybeRejectWithUndefined();
+          return;
+        }
+
+        if (!JS_DefineElement(aCx, untranslatedElements, i, wrappedElem, JSPROP_ENUMERATE)) {
+          mReturnValuePromise->MaybeRejectWithUndefined();
+          return;
+        }
+      }
+    }
+    mReturnValuePromise->MaybeResolve(untranslatedElements);
+  }
+
+  virtual void
+  RejectedCallback(JSContext* aCx, JS::Handle<JS::Value> aValue) override
+  {
+    mReturnValuePromise->MaybeRejectWithUndefined();
+  }
+
+private:
+  ~LocalizationHandler() = default;
+
+  nsTArray<nsCOMPtr<Element>> mElements;
+  RefPtr<Promise> mReturnValuePromise;
+};
+
+NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(LocalizationHandler)
+  NS_INTERFACE_MAP_ENTRY(nsISupports)
+NS_INTERFACE_MAP_END
+
+NS_IMPL_CYCLE_COLLECTION_CLASS(LocalizationHandler)
+
+NS_IMPL_CYCLE_COLLECTING_ADDREF(LocalizationHandler)
+NS_IMPL_CYCLE_COLLECTING_RELEASE(LocalizationHandler)
+
+NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN(LocalizationHandler)
+  NS_IMPL_CYCLE_COLLECTION_UNLINK(mElements)
+  NS_IMPL_CYCLE_COLLECTION_UNLINK(mReturnValuePromise)
+NS_IMPL_CYCLE_COLLECTION_UNLINK_END
+
+NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN(LocalizationHandler)
+  NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mElements)
+  NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mReturnValuePromise)
+NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END
+
+
+already_AddRefed<Promise>
+nsINode::Localize(JSContext* aCx,
+                  mozilla::dom::L10nCallback& aCallback,
+                  mozilla::ErrorResult& aRv)
+{
+  Sequence<L10nElement> l10nElements;
+  SequenceRooter<L10nElement> rooter(aCx, &l10nElements);
+  RefPtr<LocalizationHandler> nativeHandler = new LocalizationHandler();
+  nsTArray<nsCOMPtr<Element>>& domElements = nativeHandler->Elements();
+  nsIContent* node = IsContent() ? AsContent() : GetFirstChild();
+  nsAutoString l10nId;
+  nsAutoString l10nArgs;
+  nsAutoString l10nAttrs;
+  nsAutoString type;
+  for (; node; node = node->GetNextNode(this)) {
+    if (!node->IsElement()) {
+      continue;
+    }
+
+    Element* domElement = node->AsElement();
+    if (!domElement->GetAttr(kNameSpaceID_None, nsGkAtoms::datal10nid, l10nId)) {
+      continue;
+    }
+
+    domElement->GetAttr(kNameSpaceID_None, nsGkAtoms::datal10nargs, l10nArgs);
+    domElement->GetAttr(kNameSpaceID_None, nsGkAtoms::datal10nattrs, l10nAttrs);
+    L10nElement* element = l10nElements.AppendElement(fallible);
+    if (!element) {
+      aRv.Throw(NS_ERROR_OUT_OF_MEMORY);
+      return nullptr;
+    }
+    domElements.AppendElement(domElement, fallible);
+
+    domElement->GetNamespaceURI(element->mNamespaceURI);
+    element->mLocalName = domElement->LocalName();
+    domElement->GetAttr(kNameSpaceID_None, nsGkAtoms::type, type);
+    if (!type.IsEmpty()) {
+      element->mType = type;
+    }
+    element->mL10nId = l10nId;
+    if (!l10nAttrs.IsEmpty()) {
+      element->mL10nAttrs = l10nAttrs;
+    }
+    if (!l10nArgs.IsEmpty()) {
+      JS::Rooted<JS::Value> json(aCx);
+      if (!JS_ParseJSON(aCx, l10nArgs.get(), l10nArgs.Length(), &json) ||
+          !json.isObject()) {
+        aRv.Throw(NS_ERROR_DOM_SYNTAX_ERR);
+        return nullptr;
+      }
+      element->mL10nArgs = &json.toObject();
+    }
+  }
+
+  RefPtr<Promise> callbackResult = aCallback.Call(l10nElements, aRv);
+  if (aRv.Failed()) {
+    return nullptr;
+  }
+
+  RefPtr<Promise> promise = Promise::Create(OwnerDoc()->GetParentObject(), aRv);
+  if (NS_WARN_IF(aRv.Failed())) {
+    return nullptr;
+  }
+
+  nativeHandler->SetReturnValuePromise(promise);
+  callbackResult->AppendNativeHandler(nativeHandler);
+
+  return promise.forget();
+}
--- a/dom/base/nsINode.h
+++ b/dom/base/nsINode.h
@@ -78,18 +78,20 @@ class AccessibleNode;
 struct BoxQuadOptions;
 struct ConvertCoordinateOptions;
 class DocGroup;
 class DOMPoint;
 class DOMQuad;
 class DOMRectReadOnly;
 class Element;
 class EventHandlerNonNull;
+class L10nCallback;
 template<typename T> class Optional;
 class OwningNodeOrString;
+class Promise;
 template<typename> class Sequence;
 class Text;
 class TextOrElementOrDocument;
 struct DOMPointInit;
 struct GetRootNodeOptions;
 enum class CallerType : uint32_t;
 } // namespace dom
 } // namespace mozilla
@@ -1873,16 +1875,19 @@ public:
   void BindObject(nsISupports* aObject);
   // After calling UnbindObject nsINode object doesn't keep
   // aObject alive anymore.
   void UnbindObject(nsISupports* aObject);
 
   void GetBoundMutationObservers(nsTArray<RefPtr<nsDOMMutationObserver> >& aResult);
   void GenerateXPath(nsAString& aResult);
 
+  already_AddRefed<mozilla::dom::Promise>
+  Localize(JSContext* aCx, mozilla::dom::L10nCallback& aCallback, mozilla::ErrorResult& aRv);
+
   already_AddRefed<mozilla::dom::AccessibleNode> GetAccessibleNode();
 
   /**
    * Returns the length of this node, as specified at
    * <http://dvcs.w3.org/hg/domcore/raw-file/tip/Overview.html#concept-node-length>
    */
   uint32_t Length() const;
 
--- a/dom/base/test/chrome/chrome.ini
+++ b/dom/base/test/chrome/chrome.ini
@@ -63,16 +63,17 @@ support-files = ../file_bug357450.js
 [test_bug1346936.html]
 [test_cpows.xul]
 [test_getElementsWithGrid.html]
 [test_custom_element_content.xul]
 [test_custom_element_ep.xul]
 [test_domparsing.xul]
 [test_fileconstructor.xul]
 [test_nsITextInputProcessor.xul]
+[test_node_localize.xul]
 [test_permission_isHandlingUserInput.xul]
 support-files = ../dummy.html
 [test_range_getClientRectsAndTexts.html]
 [test_title.xul]
 support-files = file_title.xul
 [test_windowroot.xul]
 [test_swapFrameLoaders.xul]
 [test_bug1339722.html]
new file mode 100644
--- /dev/null
+++ b/dom/base/test/chrome/test_node_localize.xul
@@ -0,0 +1,143 @@
+<?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"?>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1363862
+-->
+<window title="Node.localize - Bug 1363862"
+  xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+  xmlns:html="http://www.w3.org/1999/xhtml">
+  <script type="application/javascript"
+          src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+
+  <!-- test results are displayed in the html:body -->
+  <body xmlns="http://www.w3.org/1999/xhtml">
+  <a href="https://bugzilla.mozilla.org/show_bug.cgi?id=1363862"
+     target="_blank">Mozilla Bug 1363862</a>
+  </body>
+
+  <!-- test code goes here -->
+  <script type="application/javascript"><![CDATA[
+
+  /** Test for Bug 1363862 **/
+
+  const translations = {
+    "key1": {
+      value: "Value 1",
+      attrs: null,
+    },
+    "key2": {
+      value: null,
+      attrs: [
+        {name: "label", value: "Value 2"},
+        {name: "accesskey", value: "K"},
+      ]
+    },
+    "key3": {
+      value: "Value 3",
+      attrs: [
+        {name: "accesskey", value: "V"},
+      ]
+    },
+    "key4": undefined,
+    "key5": {
+      value: null,
+      attrs: [
+        {name: "value", value: "Submit Value"},
+      ]
+    }
+  }
+
+  /**
+   * This function serves as an approximation of what Localization does.
+   */
+  async function mockFormatTranslations(l10nItems) {
+    testL10nItems(l10nItems);
+    return l10nItems.map(l10nItem => {
+      return translations[l10nItem.l10nId];
+    });
+  }
+
+
+  /**
+   * This function serves as an approximation of what DOMLocalization does.
+   */
+  async function translateFragment(frag) {
+    const untranslatedElements = await frag.localize(async l10nItems => {
+      const translations = await mockFormatTranslations(l10nItems);
+      return translations;
+    });
+    return untranslatedElements;
+  }
+
+
+  /**
+   * Test items returned from Node.localize to make sure they match what
+   * we would read using JS DOM.
+   */
+  function testL10nItems(l10nItems) {
+    for (l10nItem of l10nItems) {
+      const elem = document.querySelector(`[data-l10n-id=${l10nItem.l10nId}]`);
+      SimpleTest.isDeeply(
+        l10nItem.l10nArgs,
+        JSON.parse(elem.getAttribute("data-l10n-args") || null),
+        "l10nArgs should match data-l10n-args"
+      );
+      ok(l10nItem.l10nAttrs === (elem.getAttribute("data-l10n-attrs") || null),
+        "l10nAttrs should match data-l10n-attrs");
+      ok(l10nItem.localName === elem.localName,
+        "l10nItem.localeName should match elem.localName");
+      ok(l10nItem.namespaceURI === elem.namespaceURI,
+        "l10nItem.namespaceURI should match elem.namespaceURI");
+      ok(l10nItem.type === (elem.getAttribute("type") || null),
+        "l10nItem.type should match elem.type");
+    }
+  }
+
+  async function testLocalization() {
+    const container = document.getElementById("testContainer");
+
+    const untranslatedElements = await translateFragment(container);
+
+    // We will walk through all translations and check if they
+    // were correctly populated onto the DOM.
+    for (const [l10nId, translation] of Object.entries(translations)) {
+      const elem = document.querySelector(`[data-l10n-id=${l10nId}]`);
+
+      // If there is no translation then the element should be returned
+      // as part of the `untranslatedElements`.
+      if (translation === undefined) {
+        const i = Object.keys(translations).indexOf(l10nId);
+        ok(untranslatedElements[i] === elem);
+        continue;
+      }
+
+      if (translation.value !== null) {
+      ok(elem.textContent === translation.value,
+        "element's textContent should be populated with the translation value");
+      }
+
+      if (translation.attrs !== null) {
+        for (const {name, value} of translation.attrs) {
+          ok(elem.getAttribute(name) === value,
+            "attribute value should be populated from the translation");
+        }
+      }
+    }
+
+    SimpleTest.finish();
+  }
+
+  SimpleTest.waitForExplicitFinish();
+  addLoadEvent(testLocalization);
+
+  ]]></script>
+  <box id="testContainer">
+    <label data-l10n-id="key1" />
+    <label data-l10n-id="key2" data-l10n-args='{"unreadCount": 5}' />
+    <label data-l10n-id="key3" />
+    <label data-l10n-id="key4" />
+    <html:input type="submit" data-l10n-id="key5" />
+  </box>
+</window>
--- a/dom/bindings/Bindings.conf
+++ b/dom/bindings/Bindings.conf
@@ -619,16 +619,17 @@ DOMInterfaces = {
 
 'NetworkInformation': {
     'nativeType': 'mozilla::dom::network::Connection',
 },
 
 'Node': {
     'nativeType': 'nsINode',
     'concrete': False,
+    'implicitJSContext': [ 'localize' ],
 },
 
 'NodeIterator': {
     'wrapperCache': False,
 },
 
 'NodeList': {
     'nativeType': 'nsINodeList',
new file mode 100644
--- /dev/null
+++ b/dom/webidl/L10nUtils.webidl
@@ -0,0 +1,38 @@
+/* -*- Mode: IDL; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/**
+ * The following dictionaries are for Mozilla use only. They allow startup
+ * localization runtime to work around the performance cost of Stylo having
+ * to resolve XBL bindings in order to localize DOM in JS.
+ *
+ * Instead, we use `Node.localize` method which handles scanning for localizable
+ * elements and applies the result translations without having to create
+ * JS reflections for them.
+ *
+ * For details on the implementation of the API, see `Node.webidl`.
+ */
+dictionary L10nElement {
+  required DOMString namespaceURI;
+  required DOMString localName;
+  required DOMString l10nId; // value of data-l10n-id
+  DOMString? type = null;
+  DOMString? l10nAttrs = null; // value of data-l10n-attrs
+  object? l10nArgs = null; // json value of data-l10n-args attribute
+};
+
+dictionary AttributeNameValue {
+  required DOMString name;
+  required DOMString value;
+};
+
+dictionary L10nValue {
+  DOMString? value = null;
+  sequence<AttributeNameValue>? attrs = null;
+};
+
+callback L10nCallback =
+  Promise<sequence<L10nValue>> (sequence<L10nElement> l10nElements);
--- a/dom/webidl/Node.webidl
+++ b/dom/webidl/Node.webidl
@@ -107,16 +107,117 @@ interface Node : EventTarget {
   readonly attribute Principal nodePrincipal;
   [ChromeOnly]
   readonly attribute URI? baseURIObject;
   [ChromeOnly]
   sequence<MutationObserver> getBoundMutationObservers();
   [ChromeOnly]
   DOMString generateXPath();
 
+  /**
+   * This method provides a fast-path for the Fluent localization system to
+   * bypass the slowdowns in performance during initial document translation.
+   * The slowdowns are specific to XBL+Stylo.
+   * To learn more, see bug 1441037.
+   *
+   * The API is designed to fit into the DOMLocalization flow with minimal
+   * overhead, which dictates much of its signature.
+   * It takes the following steps:
+   *
+   * 1) The API can be called at any point on any DOM element and it
+   *    synchronously scans the element subtree for all children with
+   *    `data-l10n-id` attribute set.
+   *
+   * 2) Next, the API collects all of the l10n attributes
+   *    (l10n-id, l10n-args and l10n-attrs), and passes them to the
+   *    callback function together with three `Element` properties:
+   *      `name` - name of the element as lowercase
+   *      `namespaceURI` - namespace URI
+   *      `type` - the type prop of the element (used for input sanitization)
+   *
+   * 3) The callback function is responsible for (asynchronously) collecting
+   *    the translations for all l10n id+args pairs, sanitizing them and then
+   *    return them back to this API.
+   *
+   * 4) The API takes the list of elements collected in step (1) and their
+   *    translations and applies all of the translation values onto
+   *    the elements.
+   *
+   * 5) The API returns a list with empty slots for all translated elements
+   *    and references to elements that could not be translated.
+   *
+   * 6) The JS handles the translations of remaining elements.
+   *
+   *
+   * Through the whole cycle, the API uses the same list of elements and
+   * corresponding translations. It means that after step (1), the element
+   * at index 1 will match the l10nData at index 1, translations at index 1
+   * and in the final return list, the element will be also stored at index 1
+   * or the slot will be empty if the translations was applied on the C++ side.
+   *
+   * Note: There are several reasons why the JS callback may pass undefined for
+   *       a given element including missing translation, or the need to
+   *       translate the element using DOM Overlays.
+   *
+   *
+   * Example of use from JS:
+   *
+   * async function translateFragment(frag) {
+   *   let untranslatedElements = await frag.localize(
+   *     async cb(l10nItems) => {                          // 1
+   *       let trans = await getTranslations(l10nItems);   // 2
+   *       return trans;
+   *     }
+   *   );
+   *
+   *   annotateMissingTranslations(untranslatedElements);  // 3
+   * }
+   *
+   * [1] l10nItems == [
+   *       {
+   *         l10nId: "key1",
+   *         l10nArgs: null,
+   *         l10nAttrs: null,
+   *         name: "button"
+   *         namespaceURI: "..."
+   *         type: null
+   *       },
+   *       {
+   *         l10nId: "key2",
+   *         l10nArgs: {unreadCount: 5},
+   *         l10nAttrs: null,
+   *         name: "label"
+   *         namespaceURI: "..."
+   *         type: null
+   *       },
+   *       {
+   *         l10nId: "key3",
+   *         l10nArgs: null,
+   *         l10nAttrs: "title",
+   *         name: "window"
+   *         namespaceURI: "..."
+   *         type: null
+   *       },
+   *     ]
+   * [2] trans == [
+   *       {value: "Key 1", attrs: {accesskey: "K"} },
+   *       undefined,
+   *       {value: null, attrs: {title: "Unread emails: 5"} },
+   *     ]
+   * [3] untranslatedElements == [
+   *       ,
+   *       <label>
+   *       ,
+   *     ]
+   *
+   * For exact dictionary structures, see `L10nUtils.webidl`.
+   */
+  [ChromeOnly, Throws]
+  Promise<void> localize(L10nCallback l10nCallback);
+
 #ifdef ACCESSIBILITY
   [Pref="accessibility.AOM.enabled"]
   readonly attribute AccessibleNode? accessibleNode;
 #endif
 };
 
 dictionary GetRootNodeOptions {
   boolean composed = false;
--- a/dom/webidl/moz.build
+++ b/dom/webidl/moz.build
@@ -640,16 +640,17 @@ WEBIDL_FILES = [
     'IntlUtils.webidl',
     'IterableIterator.webidl',
     'KeyAlgorithm.webidl',
     'KeyboardEvent.webidl',
     'KeyEvent.webidl',
     'KeyframeAnimationOptions.webidl',
     'KeyframeEffect.webidl',
     'KeyIdsInitData.webidl',
+    'L10nUtils.webidl',
     'LegacyQueryInterface.webidl',
     'LinkStyle.webidl',
     'ListBoxObject.webidl',
     'LocalMediaStream.webidl',
     'Location.webidl',
     'MatchGlob.webidl',
     'MatchPattern.webidl',
     'MediaDeviceInfo.webidl',