Bug 1453480 - Update fluent to 0.6.4 and fluent-dom to 0.2.0. r?stas draft
authorZibi Braniecki <zbraniecki@mozilla.com>
Wed, 11 Apr 2018 13:06:35 -0700
changeset 781746 cc16e8767bbbc4be05a45d12290fa547d1b8cfda
parent 781042 ee1d1bf1dc8a83eec16967ddb61dd5024c8d6058
child 781747 b5e84af304bed1b33b2752a2e481a590ea607a81
push id106408
push userbmo:gandalf@aviary.pl
push dateFri, 13 Apr 2018 15:38:34 +0000
reviewersstas
bugs1453480
milestone61.0a1
Bug 1453480 - Update fluent to 0.6.4 and fluent-dom to 0.2.0. r?stas MozReview-Commit-ID: La8uSw0sq4p
intl/l10n/DOMLocalization.jsm
intl/l10n/Localization.jsm
intl/l10n/MessageContext.jsm
intl/l10n/fluent.js.patch
intl/l10n/test/dom/test_domloc_overlay.html
intl/l10n/test/dom/test_domloc_overlay_missing_children.html
intl/l10n/test/dom/test_domloc_overlay_repeated.html
intl/l10n/test/dom/test_domloc_repeated_l10nid.html
--- a/intl/l10n/DOMLocalization.jsm
+++ b/intl/l10n/DOMLocalization.jsm
@@ -11,35 +11,38 @@
  * Unless required by applicable law or agreed to in writing, software
  * distributed under the License is distributed on an "AS IS" BASIS,
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
 
 
-/* fluent@0.6.3 */
+/* fluent-dom@0.2.0 */
 
 const { Localization } =
   ChromeUtils.import("resource://gre/modules/Localization.jsm", {});
 
 // Match the opening angle bracket (<) in HTML tags, and HTML entities like
 // &amp;, &#0038;, &#x0026;.
 const reOverlay = /<|&#?\w+;/;
 
 /**
- * The list of elements that are allowed to be inserted into a localization.
+ * Elements allowed in translations even if they are not present in the source
+ * HTML. They are text-level elements as defined by the HTML5 spec:
+ * https://www.w3.org/TR/html5/text-level-semantics.html with the exception of:
  *
- * Source: https://www.w3.org/TR/html5/text-level-semantics.html
+ *   - a - because we don't allow href on it anyways,
+ *   - ruby, rt, rp - because we don't allow nested elements to be inserted.
  */
-const LOCALIZABLE_ELEMENTS = {
+const TEXT_LEVEL_ELEMENTS = {
   "http://www.w3.org/1999/xhtml": [
-    "a", "em", "strong", "small", "s", "cite", "q", "dfn", "abbr", "data",
+    "em", "strong", "small", "s", "cite", "q", "dfn", "abbr", "data",
     "time", "code", "var", "samp", "kbd", "sub", "sup", "i", "b", "u",
-    "mark", "ruby", "rt", "rp", "bdi", "bdo", "span", "br", "wbr"
+    "mark", "bdi", "bdo", "span", "br", "wbr"
   ],
 };
 
 const LOCALIZABLE_ATTRIBUTES = {
   "http://www.w3.org/1999/xhtml": {
     global: ["title", "aria-label", "aria-valuetext", "aria-moz-hint"],
     a: ["download"],
     area: ["download", "alt"],
@@ -61,177 +64,196 @@ const LOCALIZABLE_ATTRIBUTES = {
     key: ["key", "keycode"],
     textbox: ["placeholder"],
     toolbarbutton: ["tooltiptext"],
   }
 };
 
 
 /**
- * Overlay translation onto a DOM element.
+ * Translate an element.
  *
- * @param   {Element} targetElement
- * @param   {string|Object} translation
+ * Translate the element's text content and attributes. Some HTML markup is
+ * allowed in the translation. The element's children with the data-l10n-name
+ * attribute will be treated as arguments to the translation. If the
+ * translation defines the same children, their attributes and text contents
+ * will be used for translating the matching source child.
+ *
+ * @param   {Element} element
+ * @param   {Object} translation
  * @private
  */
-function overlayElement(targetElement, translation) {
+function translateElement(element, translation) {
   const value = translation.value;
 
   if (typeof value === "string") {
     if (!reOverlay.test(value)) {
       // If the translation doesn't contain any markup skip the overlay logic.
-      targetElement.textContent = value;
+      element.textContent = value;
     } else {
       // Else parse the translation's HTML using an inert template element,
-      // sanitize it and replace the targetElement's content.
-      const templateElement = targetElement.ownerDocument.createElementNS(
-        "http://www.w3.org/1999/xhtml", "template");
+      // sanitize it and replace the element's content.
+      const templateElement = element.ownerDocument.createElementNS(
+        "http://www.w3.org/1999/xhtml", "template"
+      );
       // eslint-disable-next-line no-unsanitized/property
       templateElement.innerHTML = value;
-      targetElement.appendChild(
-        // The targetElement will be cleared at the end of sanitization.
-        sanitizeUsing(templateElement.content, targetElement)
-      );
+      overlayChildNodes(templateElement.content, element);
     }
   }
 
-  const explicitlyAllowed = targetElement.hasAttribute("data-l10n-attrs")
-    ? targetElement.getAttribute("data-l10n-attrs")
+  // Even if the translation doesn't define any localizable attributes, run
+  // overlayAttributes to remove any localizable attributes set by previous
+  // translations.
+  overlayAttributes(translation, element);
+}
+
+/**
+ * Replace child nodes of an element with child nodes of another element.
+ *
+ * The contents of the target element will be cleared and fully replaced with
+ * sanitized contents of the source element.
+ *
+ * @param {DocumentFragment} fromElement - The source of children to overlay.
+ * @param {Element} toElement - The target of the overlay.
+ * @private
+ */
+function overlayChildNodes(fromElement, toElement) {
+  const content = toElement.ownerDocument.createDocumentFragment();
+
+  for (const childNode of fromElement.childNodes) {
+    content.appendChild(sanitizeUsing(toElement, childNode));
+  }
+
+  toElement.textContent = "";
+  toElement.appendChild(content);
+}
+
+/**
+ * Transplant localizable attributes of an element to another element.
+ *
+ * Any localizable attributes already set on the target element will be
+ * cleared.
+ *
+ * @param   {Element|Object} fromElement - The source of child nodes to overlay.
+ * @param   {Element} toElement - The target of the overlay.
+ * @private
+ */
+function overlayAttributes(fromElement, toElement) {
+  const explicitlyAllowed = toElement.hasAttribute("data-l10n-attrs")
+    ? toElement.getAttribute("data-l10n-attrs")
       .split(",").map(i => i.trim())
     : null;
 
-  // Remove localizable attributes which may have been set by a previous
-  // translation.
-  for (const attr of Array.from(targetElement.attributes)) {
-    if (isAttrNameLocalizable(attr.name, targetElement, explicitlyAllowed)) {
-      targetElement.removeAttribute(attr.name);
+  // Remove existing localizable attributes.
+  for (const attr of Array.from(toElement.attributes)) {
+    if (isAttrNameLocalizable(attr.name, toElement, explicitlyAllowed)) {
+      toElement.removeAttribute(attr.name);
     }
   }
 
-  if (translation.attrs) {
-    for (const {name, value} of translation.attrs) {
-      if (isAttrNameLocalizable(name, targetElement, explicitlyAllowed)) {
-        targetElement.setAttribute(name, value);
-      }
+  // fromElement might be a {value, attributes} object as returned by
+  // Localization.messageFromContext. In which case attributes may be null to
+  // save GC cycles.
+  if (!fromElement.attributes) {
+    return;
+  }
+
+  // Set localizable attributes.
+  for (const attr of Array.from(fromElement.attributes)) {
+    if (isAttrNameLocalizable(attr.name, toElement, explicitlyAllowed)) {
+      toElement.setAttribute(attr.name, attr.value);
     }
   }
 }
 
 /**
- * Sanitize `translationFragment` using `sourceElement` to add functional
- * HTML attributes to children.  `sourceElement` will have all its child nodes
- * removed.
+ * Sanitize a child node created by the translation.
  *
- * The sanitization is conducted according to the following rules:
- *
- *   - Allow text nodes.
- *   - Replace forbidden children with their textContent.
- *   - Remove forbidden attributes from allowed children.
+ * If childNode has the data-l10n-name attribute, try to find a corresponding
+ * child in sourceElement and use it as the base for the sanitization. This
+ * will preserve functional attribtues defined on the child element in the
+ * source HTML.
  *
- * Additionally when a child of the same type is present in `sourceElement` its
- * attributes will be merged into the translated child.  Whitelisted attributes
- * of the translated child will then overwrite the ones present in the source.
- *
- * The overlay logic is subject to the following limitations:
+ * This function must return new nodes or clones in all code paths. The
+ * returned nodes are immediately appended to the intermediate DocumentFragment
+ * which also _removes_ them from the constructed <template> containing the
+ * translation, which in turn breaks the for…of iteration over its child nodes.
  *
- *   - Children are always cloned.  Event handlers attached to them are lost.
- *   - Nested HTML in source and in translations is not supported.
- *   - Multiple children of the same type will be matched in order.
- *
- * @param {DocumentFragment} translationFragment
- * @param {Element} sourceElement
- * @returns {DocumentFragment}
+ * @param   {Element} sourceElement - The source for data-l10n-name lookups.
+ * @param   {Element} childNode - The child node to be sanitized.
+ * @returns {Element}
  * @private
  */
-function sanitizeUsing(translationFragment, sourceElement) {
-  const ownerDocument = translationFragment.ownerDocument;
-  // Take one node from translationFragment at a time and check it against
-  // the allowed list or try to match it with a corresponding element
-  // in the source.
-  for (const childNode of translationFragment.childNodes) {
-
-    if (childNode.nodeType === childNode.TEXT_NODE) {
-      continue;
-    }
-
-    // If the child is forbidden just take its textContent.
-    if (!isElementLocalizable(childNode)) {
-      const text = ownerDocument.createTextNode(childNode.textContent);
-      translationFragment.replaceChild(text, childNode);
-      continue;
-    }
-
-    // Start the sanitization with an empty element.
-    const mergedChild = ownerDocument.createElement(childNode.localName);
-
-    // Explicitly discard nested HTML by serializing childNode to a TextNode.
-    mergedChild.textContent = childNode.textContent;
-
-    // If a child of the same type exists in sourceElement, take its functional
-    // (i.e. non-localizable) attributes. This also removes the child from
-    // sourceElement.
-    const sourceChild = shiftNamedElement(sourceElement, childNode.localName);
-
-    // Find the union of all safe attributes: localizable attributes from
-    // childNode and functional attributes from sourceChild.
-    const safeAttributes = sanitizeAttrsUsing(childNode, sourceChild);
-
-    for (const attr of safeAttributes) {
-      mergedChild.setAttribute(attr.name, attr.value);
-    }
-
-    translationFragment.replaceChild(mergedChild, childNode);
+function sanitizeUsing(sourceElement, childNode) {
+  if (childNode.nodeType === childNode.TEXT_NODE) {
+    return childNode.cloneNode(false);
   }
 
-  // SourceElement might have been already modified by shiftNamedElement.
-  // Let's clear it to make sure other code doesn't rely on random leftovers.
-  sourceElement.textContent = "";
-
-  return translationFragment;
-}
+  if (childNode.hasAttribute("data-l10n-name")) {
+    const childName = childNode.getAttribute("data-l10n-name");
+    const sourceChild = sourceElement.querySelector(
+      `[data-l10n-name="${childName}"]`
+    );
 
-/**
- * Sanitize and merge attributes.
- *
- * Only localizable attributes from the translated child element and only
- * functional attributes from the source child element are considered safe.
- *
- * @param {Element} translatedElement
- * @param {Element} sourceElement
- * @returns {Array<Attr>}
- * @private
- */
-function sanitizeAttrsUsing(translatedElement, sourceElement) {
-  const localizedAttrs = Array.from(translatedElement.attributes).filter(
-    attr => isAttrNameLocalizable(attr.name, translatedElement)
+    if (!sourceChild) {
+      console.warn(
+        `An element named "${childName}" wasn't found in the source.`
+      );
+    } else if (sourceChild.localName !== childNode.localName) {
+      console.warn(
+        `An element named "${childName}" was found in the translation ` +
+        `but its type ${childNode.localName} didn't match the element ` +
+        `found in the source (${sourceChild.localName}).`
+      );
+    } else {
+      // Remove it from sourceElement so that the translation cannot use
+      // the same reference name again.
+      sourceElement.removeChild(sourceChild);
+      // We can't currently guarantee that a translation won't remove
+      // sourceChild from the element completely, which could break the app if
+      // it relies on an event handler attached to the sourceChild. Let's make
+      // this limitation explicit for now by breaking the identitiy of the
+      // sourceChild by cloning it. This will destroy all event handlers
+      // attached to sourceChild via addEventListener and via on<name>
+      // properties.
+      const clone = sourceChild.cloneNode(false);
+      return shallowPopulateUsing(childNode, clone);
+    }
+  }
+
+  if (isElementAllowed(childNode)) {
+    // Start with an empty element of the same type to remove nested children
+    // and non-localizable attributes defined by the translation.
+    const clone = childNode.ownerDocument.createElement(childNode.localName);
+    return shallowPopulateUsing(childNode, clone);
+  }
+
+  console.warn(
+    `An element of forbidden type "${childNode.localName}" was found in ` +
+    "the translation. Only elements with data-l10n-name can be overlaid " +
+    "onto source elements of the same data-l10n-name."
   );
 
-  if (!sourceElement) {
-    return localizedAttrs;
-  }
-
-  const functionalAttrs = Array.from(sourceElement.attributes).filter(
-    attr => !isAttrNameLocalizable(attr.name, sourceElement)
-  );
-
-  return localizedAttrs.concat(functionalAttrs);
+  // If all else fails, convert the element to its text content.
+  return childNode.ownerDocument.createTextNode(childNode.textContent);
 }
 
 /**
  * Check if element is allowed in the translation.
  *
  * This method is used by the sanitizer when the translation markup contains
  * an element which is not present in the source code.
  *
  * @param   {Element} element
  * @returns {boolean}
  * @private
  */
-function isElementLocalizable(element) {
-  const allowed = LOCALIZABLE_ELEMENTS[element.namespaceURI];
+function isElementAllowed(element) {
+  const allowed = TEXT_LEVEL_ELEMENTS[element.namespaceURI];
   return allowed && allowed.includes(element.localName);
 }
 
 /**
  * Check if attribute is allowed for the given element.
  *
  * This method is used by the sanitizer when the translation markup contains
  * DOM attributes, or when the translation has traits which map to DOM
@@ -282,31 +304,27 @@ function isAttrNameLocalizable(name, ele
       return true;
     }
   }
 
   return false;
 }
 
 /**
- * Remove and return the first child of the given type.
+ * Helper to set textContent and localizable attributes on an element.
  *
- * @param {DOMFragment} element
- * @param {string}      localName
- * @returns {Element | null}
+ * @param   {Element} fromElement
+ * @param   {Element} toElement
+ * @returns {Element}
  * @private
  */
-function shiftNamedElement(element, localName) {
-  for (const child of element.children) {
-    if (child.localName === localName) {
-      element.removeChild(child);
-      return child;
-    }
-  }
-  return null;
+function shallowPopulateUsing(fromElement, toElement) {
+  toElement.textContent = fromElement.textContent;
+  overlayAttributes(fromElement, toElement);
+  return toElement;
 }
 
 /**
  * Sanitizes a translation before passing them to Node.localize API.
  *
  * It returns `false` if the translation contains DOM Overlays and should
  * not go into Node.localize.
  *
@@ -327,29 +345,28 @@ function shiftNamedElement(element, loca
  * @returns boolean
  * @private
  */
 function sanitizeTranslationForNodeLocalize(l10nItem, translation) {
   if (reOverlay.test(translation.value)) {
     return false;
   }
 
-  if (translation.attrs) {
+  if (translation.attributes) {
     const explicitlyAllowed = l10nItem.l10nAttrs === null ? null :
       l10nItem.l10nAttrs.split(",").map(i => i.trim());
-    for (const [j, {name}] of translation.attrs.entries()) {
+    for (const [j, {name}] of translation.attributes.entries()) {
       if (!isAttrNameLocalizable(name, l10nItem, explicitlyAllowed)) {
-        translation.attrs.splice(j, 1);
+        translation.attributes.splice(j, 1);
       }
     }
   }
   return true;
 }
 
-
 const L10NID_ATTR_NAME = "data-l10n-id";
 const L10NARGS_ATTR_NAME = "data-l10n-args";
 
 const L10N_ELEMENT_QUERY = `[${L10NID_ATTR_NAME}]`;
 
 /**
  * The `DOMLocalization` class is responsible for fetching resources and
  * formatting translations.
@@ -538,17 +555,19 @@ class DOMLocalization extends Localizati
    * Translate mutations detected by the `MutationObserver`.
    *
    * @private
    */
   translateMutations(mutations) {
     for (const mutation of mutations) {
       switch (mutation.type) {
         case "attributes":
-          this.pendingElements.add(mutation.target);
+          if (mutation.target.hasAttribute("data-l10n-id")) {
+            this.pendingElements.add(mutation.target);
+          }
           break;
         case "childList":
           for (const addedNode of mutation.addedNodes) {
             if (addedNode.nodeType === addedNode.ELEMENT_NODE) {
               if (addedNode.childElementCount) {
                 for (const element of this.getTranslatables(addedNode)) {
                   this.pendingElements.add(element);
                 }
@@ -629,17 +648,17 @@ class DOMLocalization extends Localizati
         return translations;
       };
 
       return frag.localize(getTranslationsForItems.bind(this))
         .then(untranslatedElements => {
           for (let i = 0; i < overlayTranslations.length; i++) {
             if (overlayTranslations[i] !== undefined &&
                 untranslatedElements[i] !== undefined) {
-              overlayElement(untranslatedElements[i], overlayTranslations[i]);
+              translateElement(untranslatedElements[i], overlayTranslations[i]);
             }
           }
           this.resumeObserving();
         })
         .catch(() => this.resumeObserving());
     }
     return this.translateElements(this.getTranslatables(frag));
   }
@@ -674,17 +693,17 @@ class DOMLocalization extends Localizati
    * @param {Array<Object>}  translations
    * @private
    */
   applyTranslations(elements, translations) {
     this.pauseObserving();
 
     for (let i = 0; i < elements.length; i++) {
       if (translations[i] !== undefined) {
-        overlayElement(elements[i], translations[i]);
+        translateElement(elements[i], translations[i]);
       }
     }
 
     this.resumeObserving();
   }
 
   /**
    * Collects all translatable child elements of the element.
--- a/intl/l10n/Localization.jsm
+++ b/intl/l10n/Localization.jsm
@@ -11,17 +11,17 @@
  * Unless required by applicable law or agreed to in writing, software
  * distributed under the License is distributed on an "AS IS" BASIS,
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
 
 
-/* fluent@0.6.3 */
+/* fluent-dom@0.2.0 */
 
 /* eslint no-console: ["error", { allow: ["warn", "error"] }] */
 /* global console */
 
 const { XPCOMUtils } = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm", {});
 const { L10nRegistry } = ChromeUtils.import("resource://gre/modules/L10nRegistry.jsm", {});
 const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm", {});
 const { AppConstants } = ChromeUtils.import("resource://gre/modules/AppConstants.jsm", {});
@@ -322,25 +322,25 @@ function valueFromContext(ctx, errors, i
  * @returns {Object}
  * @private
  */
 function messageFromContext(ctx, errors, id, args) {
   const msg = ctx.getMessage(id);
 
   const formatted = {
     value: ctx.format(msg, args, errors),
-    attrs: null,
+    attributes: null,
   };
 
   if (msg.attrs) {
-    formatted.attrs = [];
-    for (const name in msg.attrs) {
-      const value = ctx.format(msg.attrs[name], args, errors);
+    formatted.attributes = [];
+    for (const [name, attr] of Object.entries(msg.attrs)) {
+      const value = ctx.format(attr, args, errors);
       if (value !== null) {
-        formatted.attrs.push({ name, value });
+        formatted.attributes.push({name, value});
       }
     }
   }
 
   return formatted;
 }
 
 /**
--- a/intl/l10n/MessageContext.jsm
+++ b/intl/l10n/MessageContext.jsm
@@ -1603,41 +1603,45 @@ function Pattern(env, ptn) {
     errors.push(new RangeError("Cyclic reference"));
     return new FluentNone();
   }
 
   // Tag the pattern as dirty for the purpose of the current resolution.
   dirty.add(ptn);
   const result = [];
 
+  // Wrap interpolations with Directional Isolate Formatting characters
+  // only when the pattern has more than one element.
+  const useIsolating = ctx._useIsolating && ptn.length > 1;
+
   for (const elem of ptn) {
     if (typeof elem === "string") {
       result.push(elem);
       continue;
     }
 
     const part = Type(env, elem).toString(ctx);
 
-    if (ctx._useIsolating) {
+    if (useIsolating) {
       result.push(FSI);
     }
 
     if (part.length > MAX_PLACEABLE_LENGTH) {
       errors.push(
         new RangeError(
           "Too many characters in placeable " +
           `(${part.length}, max allowed is ${MAX_PLACEABLE_LENGTH})`
         )
       );
       result.push(part.slice(MAX_PLACEABLE_LENGTH));
     } else {
       result.push(part);
     }
 
-    if (ctx._useIsolating) {
+    if (useIsolating) {
       result.push(PDI);
     }
   }
 
   dirty.delete(ptn);
   return result.join("");
 }
 
@@ -1770,18 +1774,26 @@ class MessageContext {
    * @returns {Array<Error>}
    */
   addMessages(source) {
     const [entries, errors] = parse(source);
     for (const id in entries) {
       if (id.startsWith("-")) {
         // Identifiers starting with a dash (-) define terms. Terms are private
         // and cannot be retrieved from MessageContext.
+        if (this._terms.has(id)) {
+          errors.push(`Attempt to override an existing term: "${id}"`);
+          continue;
+        }
         this._terms.set(id, entries[id]);
       } else {
+        if (this._messages.has(id)) {
+          errors.push(`Attempt to override an existing message: "${id}"`);
+          continue;
+        }
         this._messages.set(id, entries[id]);
       }
     }
 
     return errors;
   }
 
   /**
--- a/intl/l10n/fluent.js.patch
+++ b/intl/l10n/fluent.js.patch
@@ -1,26 +1,145 @@
 diff -uNr ./dist/DOMLocalization.jsm /home/zbraniecki/projects/mozilla-unified/intl/l10n/DOMLocalization.jsm
---- ./dist/DOMLocalization.jsm	2018-01-30 13:46:58.589811108 -0800
-+++ /home/zbraniecki/projects/mozilla-unified/intl/l10n/DOMLocalization.jsm	2018-01-30 13:46:13.613146435 -0800
-@@ -18,7 +18,8 @@
+--- ./dist/DOMLocalization.jsm	2018-04-13 08:25:21.143138950 -0700
++++ /home/zbraniecki/projects/mozilla-unified/intl/l10n/DOMLocalization.jsm	2018-04-13 08:27:11.658083766 -0700
+@@ -18,10 +18,8 @@
  
- /* fluent@0.6.0 */
+ /* fluent-dom@0.2.0 */
  
 -import Localization from '../../fluent-dom/src/localization.js';
+-
+-/* eslint no-console: ["error", {allow: ["warn"]}] */
+-/* global console */
 +const { Localization } =
-+  Components.utils.import("resource://gre/modules/Localization.jsm", {});
++  ChromeUtils.import("resource://gre/modules/Localization.jsm", {});
  
  // Match the opening angle bracket (<) in HTML tags, and HTML entities like
  // &amp;, &#0038;, &#x0026;.
-@@ -623,36 +624,5 @@
+@@ -96,6 +94,7 @@
+       const templateElement = element.ownerDocument.createElementNS(
+         "http://www.w3.org/1999/xhtml", "template"
+       );
++      // eslint-disable-next-line no-unsanitized/property
+       templateElement.innerHTML = value;
+       overlayChildNodes(templateElement.content, element);
+     }
+@@ -323,6 +322,46 @@
+   return toElement;
+ }
+ 
++/**
++ * Sanitizes a translation before passing them to Node.localize API.
++ *
++ * It returns `false` if the translation contains DOM Overlays and should
++ * not go into Node.localize.
++ *
++ * Note: There's a third item of work that JS DOM Overlays do - removal
++ * of attributes from the previous translation.
++ * This is not trivial to implement for Node.localize scenario, so
++ * at the moment it is not supported.
++ *
++ * @param {{
++ *          localName: string,
++ *          namespaceURI: string,
++ *          type: string || null
++ *          l10nId: string,
++ *          l10nArgs: Array<Object> || null,
++ *          l10nAttrs: string ||null,
++ *        }}                                     l10nItems
++ * @param {{value: string, attrs: Object}} translations
++ * @returns boolean
++ * @private
++ */
++function sanitizeTranslationForNodeLocalize(l10nItem, translation) {
++  if (reOverlay.test(translation.value)) {
++    return false;
++  }
++
++  if (translation.attributes) {
++    const explicitlyAllowed = l10nItem.l10nAttrs === null ? null :
++      l10nItem.l10nAttrs.split(",").map(i => i.trim());
++    for (const [j, {name}] of translation.attributes.entries()) {
++      if (!isAttrNameLocalizable(name, l10nItem, explicitlyAllowed)) {
++        translation.attributes.splice(j, 1);
++      }
++    }
++  }
++  return true;
++}
++
+ const L10NID_ATTR_NAME = "data-l10n-id";
+ const L10NARGS_ATTR_NAME = "data-l10n-args";
+ 
+@@ -568,6 +607,59 @@
+    * @returns {Promise}
+    */
+   translateFragment(frag) {
++    if (frag.localize) {
++      // This is a temporary fast-path offered by Gecko to workaround performance
++      // issues coming from Fluent and XBL+Stylo performing unnecesary
++      // operations during startup.
++      // For details see bug 1441037, bug 1442262, and bug 1363862.
++
++      // A sparse array which will store translations separated out from
++      // all translations that is needed for DOM Overlay.
++      const overlayTranslations = [];
++
++      const getTranslationsForItems = async l10nItems => {
++        const keys = l10nItems.map(l10nItem => [l10nItem.l10nId, l10nItem.l10nArgs]);
++        const translations = await this.formatMessages(keys);
++
++        // Here we want to separate out elements that require DOM Overlays.
++        // Those elements will have to be translated using our JS
++        // implementation, while everything else is going to use the fast-path.
++        for (const [i, translation] of translations.entries()) {
++          if (translation === undefined) {
++            continue;
++          }
++
++          const hasOnlyText =
++            sanitizeTranslationForNodeLocalize(l10nItems[i], translation);
++          if (!hasOnlyText) {
++            // Removing from translations to make Node.localize skip it.
++            // We will translate it below using JS DOM Overlays.
++            overlayTranslations[i] = translations[i];
++            translations[i] = undefined;
++          }
++        }
++
++        // We pause translation observing here because Node.localize
++        // will translate the whole DOM next, using the `translations`.
++        //
++        // The observer will be resumed after DOM Overlays are localized
++        // in the next microtask.
++        this.pauseObserving();
++        return translations;
++      };
++
++      return frag.localize(getTranslationsForItems.bind(this))
++        .then(untranslatedElements => {
++          for (let i = 0; i < overlayTranslations.length; i++) {
++            if (overlayTranslations[i] !== undefined &&
++                untranslatedElements[i] !== undefined) {
++              translateElement(untranslatedElements[i], overlayTranslations[i]);
++            }
++          }
++          this.resumeObserving();
++        })
++        .catch(() => this.resumeObserving());
++    }
+     return this.translateElements(this.getTranslatables(frag));
+   }
+ 
+@@ -647,37 +739,5 @@
    }
  }
  
 -/* global L10nRegistry, Services */
+-
 -/**
 - * The default localization strategy for Gecko. It comabines locales
 - * available in L10nRegistry, with locales requested by the user to
 - * generate the iterator over MessageContexts.
 - *
 - * In the future, we may want to allow certain modules to override this
 - * with a different negotitation strategy to allow for the module to
 - * be localized into a different language - for example DevTools.
@@ -42,50 +161,94 @@ diff -uNr ./dist/DOMLocalization.jsm /ho
 -    resourceIds,
 -    generateMessages = defaultGenerateMessages
 -  ) {
 -    super(windowElement, resourceIds, generateMessages);
 -  }
 -}
 -
 -this.DOMLocalization = GeckoDOMLocalization;
+-this.EXPORTED_SYMBOLS = ["DOMLocalization"];
 +this.DOMLocalization = DOMLocalization;
- this.EXPORTED_SYMBOLS = ['DOMLocalization'];
++var EXPORTED_SYMBOLS = ["DOMLocalization"];
 diff -uNr ./dist/l10n.js /home/zbraniecki/projects/mozilla-unified/intl/l10n/l10n.js
---- ./dist/l10n.js	2018-01-30 13:46:58.749811101 -0800
-+++ /home/zbraniecki/projects/mozilla-unified/intl/l10n/l10n.js	2018-01-26 20:52:09.106650798 -0800
-@@ -1,7 +1,6 @@
+--- ./dist/l10n.js	2018-04-13 08:25:21.307139138 -0700
++++ /home/zbraniecki/projects/mozilla-unified/intl/l10n/l10n.js	2018-04-13 08:27:25.230296529 -0700
+@@ -1,20 +1,26 @@
 -/* global Components, document, window */
  {
    const { DOMLocalization } =
--    Components.utils.import('resource://gre/modules/DOMLocalization.jsm');
-+    Components.utils.import("resource://gre/modules/DOMLocalization.jsm");
+-    Components.utils.import("resource://gre/modules/DOMLocalization.jsm");
++    ChromeUtils.import("resource://gre/modules/DOMLocalization.jsm", {});
  
    /**
     * Polyfill for document.ready polyfill.
+    * See: https://github.com/whatwg/html/issues/127 for details.
+    *
++   * XXX: The callback is a temporary workaround for bug 1193394. Once Promises in Gecko
++   *      start beeing a microtask and stop pushing translation post-layout, we can
++   *      remove it and start using the returned Promise again.
++   *
++   * @param {Function} callback - function to be called when the document is ready.
+    * @returns {Promise}
+    */
+-  function documentReady() {
++  function documentReady(callback) {
+     if (document.contentType === "application/vnd.mozilla.xul+xml") {
+       // XUL
+       return new Promise(
+         resolve => document.addEventListener(
+-          "MozBeforeInitialXULLayout", resolve, { once: true }
++          "MozBeforeInitialXULLayout", () => {
++            resolve(callback());
++          }, { once: true }
+         )
+       );
+     }
+@@ -22,11 +28,13 @@
+     // HTML
+     const rs = document.readyState;
+     if (rs === "interactive" || rs === "completed") {
+-      return Promise.resolve();
++      return Promise.resolve(callback);
+     }
+     return new Promise(
+       resolve => document.addEventListener(
+-        "readystatechange", resolve, { once: true }
++        "readystatechange", () => {
++          resolve(callback());
++        }, { once: true }
+       )
+     );
+   }
+@@ -50,11 +58,8 @@
+   // trigger first context to be fetched eagerly
+   document.l10n.ctxs.touchNext();
+ 
+-  document.l10n.ready = documentReady().then(() => {
++  document.l10n.ready = documentReady(() => {
+     document.l10n.registerObservers();
+-    window.addEventListener("unload", () => {
+-      document.l10n.unregisterObservers();
+-    });
+     document.l10n.connectRoot(document.documentElement);
+     return document.l10n.translateRoots();
+   });
 diff -uNr ./dist/Localization.jsm /home/zbraniecki/projects/mozilla-unified/intl/l10n/Localization.jsm
---- ./dist/Localization.jsm	2018-01-30 13:46:58.393144450 -0800
-+++ /home/zbraniecki/projects/mozilla-unified/intl/l10n/Localization.jsm	2018-01-30 13:46:04.593146834 -0800
-@@ -18,92 +18,16 @@
+--- ./dist/Localization.jsm	2018-04-13 08:25:20.946138732 -0700
++++ /home/zbraniecki/projects/mozilla-unified/intl/l10n/Localization.jsm	2018-04-13 08:27:16.396155987 -0700
+@@ -18,70 +18,13 @@
  
- /* fluent@0.6.0 */
+ /* fluent-dom@0.2.0 */
  
 -/*  eslint no-magic-numbers: [0]  */
 -
 -/* global Intl */
 -
 -/**
-- * The `FluentType` class is the base of Fluent's type system.
-- *
-- * Fluent types wrap JavaScript values and store additional configuration for
-- * them, which can then be used in the `toString` method together with a proper
-- * `Intl` formatter.
-- */
--
--/**
 - * @overview
 - *
 - * The FTL resolver ships with a number of functions built-in.
 - *
 - * Each function take two arguments:
 - *   - args - an array of positional args
 - *   - opts - an object of key-value args
 - *
@@ -137,40 +300,24 @@ diff -uNr ./dist/Localization.jsm /home/
 - *  * {Object} args
 - *      list of developer provided arguments that can be used
 - *  * {Array} errors
 - *      list of errors collected while resolving
 - *  * {WeakSet} dirty
 - *      Set of patterns already encountered during this resolution.
 - *      This is used to prevent cyclic resolutions.
 - */
-+const Cu = Components.utils;
-+const Cc = Components.classes;
-+const Ci = Components.interfaces;
- 
--/**
-- * Message contexts are single-language stores of translations.  They are
-- * responsible for parsing translation resources in the Fluent syntax and can
-- * format translation units (entities) to strings.
-- *
-- * Always use `MessageContext.format` to retrieve translation units from
-- * a context.  Translations can contain references to other entities or
-- * external arguments, conditional logic in form of select expressions, traits
-- * which describe their grammatical features, and can use Fluent builtins which
-- * make use of the `Intl` formatters to format numbers, dates, lists and more
-- * into the context's language.  See the documentation of the Fluent syntax for
-- * more information.
-- */
-+const { L10nRegistry } = Cu.import("resource://gre/modules/L10nRegistry.jsm", {});
-+const LocaleService = Cc["@mozilla.org/intl/localeservice;1"].getService(Ci.mozILocaleService);
-+const ObserverService = Cc["@mozilla.org/observer-service;1"].getService(Ci.nsIObserverService);
++const { XPCOMUtils } = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm", {});
++const { L10nRegistry } = ChromeUtils.import("resource://gre/modules/L10nRegistry.jsm", {});
++const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm", {});
++const { AppConstants } = ChromeUtils.import("resource://gre/modules/AppConstants.jsm", {});
  
  /*
   * CachedIterable caches the elements yielded by an iterable.
-@@ -170,87 +94,6 @@
+@@ -148,58 +91,19 @@
    }
  }
  
 -/*
 - * @overview
 - *
 - * Functions for managing ordered sequences of MessageContexts.
 - *
@@ -207,171 +354,133 @@ diff -uNr ./dist/Localization.jsm /home/
 - *     }
 - *
 - *     const contexts = new CachedIterable(generateMessages());
 - *     const ctx = mapContextSync(contexts, id);
 - *
 - */
 -
 -/*
-- * Synchronously map an identifier or an array of identifiers to the best
-- * `MessageContext` instance(s).
-- *
-- * @param {Iterable} iterable
-- * @param {string|Array<string>} ids
-- * @returns {MessageContext|Array<MessageContext>}
-- */
--
--
--/*
-- * Asynchronously map an identifier or an array of identifiers to the best
-- * `MessageContext` instance(s).
-- *
-- * @param {AsyncIterable} iterable
-- * @param {string|Array<string>} ids
-- * @returns {Promise<MessageContext|Array<MessageContext>>}
-- */
--
--/**
-- * Template literal tag for dedenting FTL code.
-- *
-- * Strip the common indent of non-blank lines. Remove blank lines.
-- *
-- * @param {Array<string>} strings
-- */
--
--/*
 - * @module fluent
 - * @overview
 - *
 - * `fluent` is a JavaScript implementation of Project Fluent, a localization
 - * framework designed to unleash the expressive power of the natural language.
-- *
-- */
--
--/* eslint no-console: ["error", { allow: ["warn", "error"] }] */
--/* global console */
--
- /**
-  * Specialized version of an Error used to indicate errors that are result
-  * of a problem during the localization process.
-@@ -269,6 +112,26 @@
-   }
- }
- 
-+ /**
++/**
 + * The default localization strategy for Gecko. It comabines locales
 + * available in L10nRegistry, with locales requested by the user to
 + * generate the iterator over MessageContexts.
-+ *
+  *
 + * In the future, we may want to allow certain modules to override this
 + * with a different negotitation strategy to allow for the module to
 + * be localized into a different language - for example DevTools.
-+ */
+  */
+-
+-/* eslint no-console: ["error", { allow: ["warn", "error"] }] */
 +function defaultGenerateMessages(resourceIds) {
-+  const availableLocales = L10nRegistry.getAvailableLocales();
-+
-+  const requestedLocales = LocaleService.getRequestedLocales();
-+  const defaultLocale = LocaleService.defaultLocale;
-+  const locales = LocaleService.negotiateLanguages(
-+    requestedLocales, availableLocales, defaultLocale,
-+  );
-+  return L10nRegistry.generateContexts(locales, resourceIds);
++  const appLocales = Services.locale.getAppLocalesAsLangTags();
++  return L10nRegistry.generateContexts(appLocales, resourceIds);
 +}
-+
+ 
  /**
   * The `Localization` class is a central high-level API for vanilla
-  * JavaScript use of Fluent.
-@@ -283,7 +146,7 @@
+@@ -215,7 +119,7 @@
     *
     * @returns {Localization}
     */
 -  constructor(resourceIds, generateMessages) {
 +  constructor(resourceIds, generateMessages = defaultGenerateMessages) {
      this.resourceIds = resourceIds;
      this.generateMessages = generateMessages;
      this.ctxs = new CachedIterable(this.generateMessages(this.resourceIds));
-@@ -303,7 +166,7 @@
-    */
+@@ -236,7 +140,7 @@
    async formatWithFallback(keys, method) {
      const translations = [];
+ 
 -    for (let ctx of this.ctxs) {
 +    for await (let ctx of this.ctxs) {
        // This can operate on synchronous and asynchronous
        // contexts coming from the iterator.
-       if (typeof ctx.then === 'function') {
-@@ -394,8 +257,38 @@
+       if (typeof ctx.then === "function") {
+@@ -248,7 +152,7 @@
+         break;
+       }
+ 
+-      if (typeof console !== "undefined") {
++      if (AppConstants.NIGHTLY_BUILD) {
+         const locale = ctx.locales[0];
+         const ids = Array.from(missingIds).join(", ");
+         console.warn(`Missing translations in ${locale}: ${ids}`);
+@@ -335,8 +239,28 @@
      return val;
    }
  
 -  handleEvent() {
 -    this.onLanguageChange();
 +  /**
-+   * Register observers on events that will trigger cache invalidation
++   * Register weak observers on events that will trigger cache invalidation
 +   */
 +  registerObservers() {
-+    ObserverService.addObserver(this, 'l10n:available-locales-changed', false);
-+    ObserverService.addObserver(this, 'intl:requested-locales-changed', false);
-+  }
-+
-+  /**
-+   * Unregister observers on events that will trigger cache invalidation
-+   */
-+  unregisterObservers() {
-+    ObserverService.removeObserver(this, 'l10n:available-locales-changed');
-+    ObserverService.removeObserver(this, 'intl:requested-locales-changed');
++    Services.obs.addObserver(this, "intl:app-locales-changed", true);
 +  }
 +
 +  /**
 +   * Default observer handler method.
 +   *
 +   * @param {String} subject
 +   * @param {String} topic
 +   * @param {Object} data
 +   */
 +  observe(subject, topic, data) {
 +    switch (topic) {
-+      case 'l10n:available-locales-changed':
-+      case 'intl:requested-locales-changed':
++      case "intl:app-locales-changed":
 +        this.onLanguageChange();
 +        break;
 +      default:
 +        break;
 +    }
    }
  
    /**
-@@ -538,7 +431,8 @@
-       hasErrors = true;
-     }
+@@ -348,6 +272,10 @@
+   }
+ }
  
--    if (messageErrors.length && typeof console !== 'undefined') {
-+    if (messageErrors.length) {
-+      const { console } = Cu.import("resource://gre/modules/Console.jsm", {});
-       messageErrors.forEach(error => console.warn(error));
-     }
-   });
-@@ -546,45 +440,5 @@
-   return hasErrors;
++Localization.prototype.QueryInterface = XPCOMUtils.generateQI([
++  Ci.nsISupportsWeakReference
++]);
++
+ /**
+  * Format the value of a message into a string.
+  *
+@@ -368,6 +296,7 @@
+  */
+ function valueFromContext(ctx, errors, id, args) {
+   const msg = ctx.getMessage(id);
++
+   return ctx.format(msg, args, errors);
+ }
+ 
+@@ -467,44 +396,5 @@
+   return missingIds;
  }
  
 -/* global Components */
 -/* eslint no-unused-vars: 0 */
 -
 -const Cu = Components.utils;
 -const Cc = Components.classes;
 -const Ci = Components.interfaces;
 -
 -const { L10nRegistry } =
--  Cu.import('resource://gre/modules/L10nRegistry.jsm', {});
+-  Cu.import("resource://gre/modules/L10nRegistry.jsm", {});
 -const ObserverService =
--  Cc['@mozilla.org/observer-service;1'].getService(Ci.nsIObserverService);
+-  Cc["@mozilla.org/observer-service;1"].getService(Ci.nsIObserverService);
 -const { Services } =
--  Cu.import('resource://gre/modules/Services.jsm', {});
--
+-  Cu.import("resource://gre/modules/Services.jsm", {});
 -
 -/**
 - * The default localization strategy for Gecko. It comabines locales
 - * available in L10nRegistry, with locales requested by the user to
 - * generate the iterator over MessageContexts.
 - *
 - * In the future, we may want to allow certain modules to override this
 - * with a different negotitation strategy to allow for the module to
@@ -389,22 +498,32 @@ diff -uNr ./dist/Localization.jsm /home/
 -
 -class GeckoLocalization extends Localization {
 -  constructor(resourceIds, generateMessages = defaultGenerateMessages) {
 -    super(resourceIds, generateMessages);
 -  }
 -}
 -
 -this.Localization = GeckoLocalization;
+-this.EXPORTED_SYMBOLS = ["Localization"];
 +this.Localization = Localization;
- this.EXPORTED_SYMBOLS = ['Localization'];
++var EXPORTED_SYMBOLS = ["Localization"];
 diff -uNr ./dist/MessageContext.jsm /home/zbraniecki/projects/mozilla-unified/intl/l10n/MessageContext.jsm
---- ./dist/MessageContext.jsm	2018-01-30 13:46:58.119811129 -0800
-+++ /home/zbraniecki/projects/mozilla-unified/intl/l10n/MessageContext.jsm	2018-01-30 13:53:23.036460739 -0800
-@@ -1838,90 +1838,5 @@
+--- ./dist/MessageContext.jsm	2018-04-13 08:25:20.698138486 -0700
++++ /home/zbraniecki/projects/mozilla-unified/intl/l10n/MessageContext.jsm	2018-04-13 08:27:20.944227388 -0700
+@@ -16,7 +16,7 @@
+  */
+ 
+ 
+-/* fluent-dom@0.2.0 */
++/* fluent@0.6.3 */
+ 
+ /*  eslint no-magic-numbers: [0]  */
+ 
+@@ -1858,63 +1858,5 @@
    }
  }
  
 -/*
 - * CachedIterable caches the elements yielded by an iterable.
 - *
 - * It can be used to iterate over an iterable many times without depleting the
 - * iterable.
@@ -448,45 +567,19 @@ diff -uNr ./dist/MessageContext.jsm /hom
 - *     }
 - *
 - *     const contexts = new CachedIterable(generateMessages());
 - *     const ctx = mapContextSync(contexts, id);
 - *
 - */
 -
 -/*
-- * Synchronously map an identifier or an array of identifiers to the best
-- * `MessageContext` instance(s).
-- *
-- * @param {Iterable} iterable
-- * @param {string|Array<string>} ids
-- * @returns {MessageContext|Array<MessageContext>}
-- */
--
--
--/*
-- * Asynchronously map an identifier or an array of identifiers to the best
-- * `MessageContext` instance(s).
-- *
-- * @param {AsyncIterable} iterable
-- * @param {string|Array<string>} ids
-- * @returns {Promise<MessageContext|Array<MessageContext>>}
-- */
--
--/**
-- * Template literal tag for dedenting FTL code.
-- *
-- * Strip the common indent of non-blank lines. Remove blank lines.
-- *
-- * @param {Array<string>} strings
-- */
--
--/*
 - * @module fluent
 - * @overview
 - *
 - * `fluent` is a JavaScript implementation of Project Fluent, a localization
 - * framework designed to unleash the expressive power of the natural language.
 - *
 - */
 -
  this.MessageContext = MessageContext;
- this.EXPORTED_SYMBOLS = ['MessageContext'];
+-this.EXPORTED_SYMBOLS = ["MessageContext"];
++var EXPORTED_SYMBOLS = ["MessageContext"];
--- a/intl/l10n/test/dom/test_domloc_overlay.html
+++ b/intl/l10n/test/dom/test_domloc_overlay.html
@@ -10,17 +10,17 @@
   const { DOMLocalization } =
     ChromeUtils.import("resource://gre/modules/DOMLocalization.jsm", {});
   const { MessageContext } =
     ChromeUtils.import("resource://gre/modules/MessageContext.jsm", {});
 
   async function* mockGenerateMessages(locales, resourceIds) {
     const mc = new MessageContext(locales);
     mc.addMessages("title = <strong>Hello</strong> World");
-    mc.addMessages("title2 = This is <a>a link</a>!");
+    mc.addMessages(`title2 = This is <a data-l10n-name="link">a link</a>!`);
     yield mc;
   }
 
   window.onload = async function() {
     SimpleTest.waitForExplicitFinish();
 
     const domLoc = new DOMLocalization(
       window,
@@ -45,12 +45,12 @@
 
     a.click();
   };
   </script>
 </head>
 <body>
   <p data-l10n-id="title" />
   <p data-l10n-id="title2">
-    <a href="http://www.mozilla.org"></a>
+    <a href="http://www.mozilla.org" data-l10n-name="link"></a>
   </p>
 </body>
 </html>
--- a/intl/l10n/test/dom/test_domloc_overlay_missing_children.html
+++ b/intl/l10n/test/dom/test_domloc_overlay_missing_children.html
@@ -9,17 +9,17 @@
   "use strict";
   const { DOMLocalization } =
     ChromeUtils.import("resource://gre/modules/DOMLocalization.jsm", {});
   const { MessageContext } =
     ChromeUtils.import("resource://gre/modules/MessageContext.jsm", {});
 
   async function* mockGenerateMessages(locales, resourceIds) {
     const mc = new MessageContext(locales);
-    mc.addMessages("title = Visit <a>Mozilla</a> or <a>Firefox</a> website!");
+    mc.addMessages(`title = Visit <a data-l10n-name="mozilla-link">Mozilla</a> or <a data-l10n-name="firefox-link">Firefox</a> website!`);
     yield mc;
   }
 
   window.onload = async function() {
     SimpleTest.waitForExplicitFinish();
 
     const domLoc = new DOMLocalization(
       window,
@@ -41,14 +41,14 @@
     is(linkList.length, 2, "There should be exactly two links in the result.");
 
     SimpleTest.finish();
   };
   </script>
 </head>
 <body>
   <p data-l10n-id="title">
-    <a href="http://www.mozilla.org"></a>
-    <a href="http://www.firefox.com"></a>
-    <a href="http://www.w3.org"></a>
+    <a href="http://www.mozilla.org" data-l10n-name="mozilla-link"></a>
+    <a href="http://www.firefox.com" data-l10n-name="firefox-link"></a>
+    <a href="http://www.w3.org" data-l10n-name="w3-link"></a>
   </p>
 </body>
 </html>
--- a/intl/l10n/test/dom/test_domloc_overlay_repeated.html
+++ b/intl/l10n/test/dom/test_domloc_overlay_repeated.html
@@ -9,17 +9,17 @@
   "use strict";
   const { DOMLocalization } =
     ChromeUtils.import("resource://gre/modules/DOMLocalization.jsm", {});
   const { MessageContext } =
     ChromeUtils.import("resource://gre/modules/MessageContext.jsm", {});
 
   async function* mockGenerateMessages(locales, resourceIds) {
     const mc = new MessageContext(locales);
-    mc.addMessages("title = Visit <a>Mozilla</a> or <a>Firefox</a> website!");
+    mc.addMessages(`title = Visit <a data-l10n-name="mozilla-link">Mozilla</a> or <a data-l10n-name="firefox-link">Firefox</a> website!`);
     yield mc;
   }
 
   window.onload = async function() {
     SimpleTest.waitForExplicitFinish();
 
     const domLoc = new DOMLocalization(
       window,
@@ -39,13 +39,13 @@
     is(linkList[1].textContent, "Firefox");
 
     SimpleTest.finish();
   };
   </script>
 </head>
 <body>
   <p data-l10n-id="title">
-    <a href="http://www.mozilla.org"></a>
-    <a href="http://www.firefox.com"></a>
+    <a href="http://www.mozilla.org" data-l10n-name="mozilla-link"></a>
+    <a href="http://www.firefox.com" data-l10n-name="firefox-link"></a>
   </p>
 </body>
 </html>
--- a/intl/l10n/test/dom/test_domloc_repeated_l10nid.html
+++ b/intl/l10n/test/dom/test_domloc_repeated_l10nid.html
@@ -12,17 +12,17 @@
   const { MessageContext } =
     ChromeUtils.import("resource://gre/modules/MessageContext.jsm", {});
 
   async function* mockGenerateMessages(locales, resourceIds) {
     const mc = new MessageContext(locales);
     mc.addMessages(`
 key1 = Translation For Key 1
 
-key2 = Visit <a>this link<a/>.
+key2 = Visit <a data-l10n-name="link">this link<a/>.
     `);
     yield mc;
   }
 
   SimpleTest.waitForExplicitFinish();
   addLoadEvent(async () => {
     const domLoc = new DOMLocalization(
       window,
@@ -48,16 +48,16 @@ key2 = Visit <a>this link<a/>.
   });
   </script>
 </head>
 <body>
   <h1 id="elem1" data-l10n-id="key1"></h1>
   <h2 id="elem2" data-l10n-id="key1"></h2>
 
   <p id="elem3" data-l10n-id="key2">
-    <a href="http://www.mozilla.org"></a>
+    <a href="http://www.mozilla.org" data-l10n-name="link"></a>
   </p>
 
   <p id="elem4" data-l10n-id="key2">
-    <a href="http://www.firefox.com"></a>
+    <a href="http://www.firefox.com" data-l10n-name="link"></a>
   </p>
 </body>
 </html>