Bug 1363862 - Add Node.localize API as a fast-path for Fluent DOM localization. r?baku draft
authorZibi Braniecki <zbraniecki@mozilla.com>
Wed, 21 Feb 2018 14:07:53 -0800
changeset 763479 35cfdb0cdbfce3fac2cb1f14f39064734e55037d
parent 763311 0ef34a9ec4fbfccd03ee0cfb26b182c03e28133a
child 763480 2eef3764eafbc312d7f2de720adc41f8bd1446b6
push id101466
push userbmo:gandalf@aviary.pl
push dateTue, 06 Mar 2018 01:43:38 +0000
reviewersbaku
bugs1363862
milestone60.0a1
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',