Bug 1363862 - Add Node.localize API as a fast-path for Fluent DOM localization. r?baku
MozReview-Commit-ID: 6mj0q21Nbey
--- 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',