Bug 1455649 - Implement DocumentL10n WebIDL. draft
authorZibi Braniecki <zbraniecki@mozilla.com>
Tue, 22 May 2018 11:50:09 -0700
changeset 800078 4916a7ff2afb87fd966bbf270265789e21d38ffb
parent 800077 366f3cb06f6447bc0fdd83b50f74b879a937b1a9
child 800079 41340062fda1ed485eb0c1173720d2b171146ec6
push id111260
push userbmo:gandalf@aviary.pl
push dateFri, 25 May 2018 20:47:52 +0000
bugs1455649
milestone62.0a1
Bug 1455649 - Implement DocumentL10n WebIDL. Change the laziness structure of DocumentL10n. MozReview-Commit-ID: DAfqJfS0s74
dom/base/nsDocument.cpp
dom/base/nsDocument.h
dom/base/nsIDocument.h
dom/html/HTMLBodyElement.cpp
dom/html/HTMLLinkElement.cpp
dom/webidl/Document.webidl
dom/webidl/DocumentL10n.webidl
dom/webidl/moz.build
dom/xul/XULDocument.cpp
intl/l10n/DocumentL10n.cpp
intl/l10n/DocumentL10n.h
intl/l10n/moz.build
intl/l10n/test/chrome.ini
intl/l10n/test/document_l10n/html/test_dom_loc.html
intl/l10n/test/document_l10n/html/test_no_loc.html
intl/l10n/test/document_l10n/xul/test_api_before_links.xul
intl/l10n/test/document_l10n/xul/test_dom_loc.xul
intl/l10n/test/document_l10n/xul/test_lazy_resource_injection.xul
intl/l10n/test/document_l10n/xul/test_lazy_webcomponent.xul
intl/l10n/test/document_l10n/xul/test_no_loc.xul
intl/l10n/test/document_l10n/xul/test_no_loc_api_calls.xul
xpcom/ds/nsGkAtomList.h
--- a/dom/base/nsDocument.cpp
+++ b/dom/base/nsDocument.cpp
@@ -204,16 +204,17 @@
 #include "nsWrapperCacheInlines.h"
 #include "nsSandboxFlags.h"
 #include "mozilla/dom/AnimatableBinding.h"
 #include "mozilla/dom/AnonymousContent.h"
 #include "mozilla/dom/BindingUtils.h"
 #include "mozilla/dom/ClientInfo.h"
 #include "mozilla/dom/ClientState.h"
 #include "mozilla/dom/DocumentFragment.h"
+#include "mozilla/dom/DocumentL10n.h"
 #include "mozilla/dom/DocumentTimeline.h"
 #include "mozilla/dom/Event.h"
 #include "mozilla/dom/HTMLBodyElement.h"
 #include "mozilla/dom/HTMLInputElement.h"
 #include "mozilla/dom/ImageTracker.h"
 #include "mozilla/dom/MediaQueryList.h"
 #include "mozilla/dom/NodeFilterBinding.h"
 #include "mozilla/OwningNonNull.h"
@@ -1369,16 +1370,20 @@ nsIDocument::nsIDocument()
     mCharacterSet(WINDOWS_1252_ENCODING),
     mCharacterSetSource(0),
     mParentDocument(nullptr),
     mCachedRootElement(nullptr),
     mNodeInfoManager(nullptr),
 #ifdef DEBUG
     mStyledLinksCleared(false),
 #endif
+    mDocumentL10n(nullptr),
+    mL10nResourceContainerParsed(false),
+    mDOMParsed(false),
+    mPrincipalAllowsL10n(false),
     mBidiEnabled(false),
     mMathMLEnabled(false),
     mIsInitialDocumentInWindow(false),
     mIgnoreDocGroupMismatches(false),
     mLoadedAsData(false),
     mLoadedAsInteractiveData(false),
     mMayStartLayout(true),
     mHaveFiredTitleChange(false),
@@ -1857,16 +1862,17 @@ NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN_
   }
 
   // Traverse all nsIDocument pointer members.
   NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mSecurityInfo)
   NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mDisplayDocument)
   NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mFontFaceSet)
   NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mReadyForIdle)
   NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mAboutCapabilities)
+  NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mDocumentL10n)
 
   // Traverse all nsDocument nsCOMPtrs.
   NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mParser)
   NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mScriptGlobalObject)
   NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mListenerManager)
   NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mDOMStyleSheets)
   NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mStyleSheetSetList)
   NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mScriptLoader)
@@ -2012,16 +2018,17 @@ NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN(ns
   NS_IMPL_CYCLE_COLLECTION_UNLINK(mForms);
   NS_IMPL_CYCLE_COLLECTION_UNLINK(mScripts);
   NS_IMPL_CYCLE_COLLECTION_UNLINK(mApplets);
   NS_IMPL_CYCLE_COLLECTION_UNLINK(mAnchors);
   NS_IMPL_CYCLE_COLLECTION_UNLINK(mOrientationPendingPromise)
   NS_IMPL_CYCLE_COLLECTION_UNLINK(mFontFaceSet)
   NS_IMPL_CYCLE_COLLECTION_UNLINK(mReadyForIdle);
   NS_IMPL_CYCLE_COLLECTION_UNLINK(mAboutCapabilities)
+  NS_IMPL_CYCLE_COLLECTION_UNLINK(mDocumentL10n)
 
   tmp->mParentDocument = nullptr;
 
   NS_IMPL_CYCLE_COLLECTION_UNLINK(mPreloadingImages)
 
   NS_IMPL_CYCLE_COLLECTION_UNLINK(mIntersectionObservers)
 
   tmp->ClearAllBoxObjects();
@@ -2210,16 +2217,44 @@ nsIDocument::Reset(nsIChannel* aChannel,
       mDocumentBaseURI = baseURI;
       mChromeXHRDocBaseURI = nullptr;
     }
   }
 
   mChannel = aChannel;
 }
 
+bool
+PrincipalAllowsL10n(nsIPrincipal* principal) {
+  // Fast track privileged contexts
+  if (nsContentUtils::IsSystemPrincipal(principal)) {
+    return true;
+  }
+
+  nsCOMPtr<nsIURI> uri;
+  nsresult rv = principal->GetURI(getter_AddRefs(uri));
+  if (NS_FAILED(rv) || !uri) {
+    return false;
+  }
+
+  bool isAbout;
+  rv = uri->SchemeIs("about", &isAbout);
+  if (NS_FAILED(rv) || (!isAbout)) {
+    return false;
+  }
+
+  bool isNonWeb;
+  rv = NS_URIChainHasFlags(uri, nsIProtocolHandler::URI_DANGEROUS_TO_LOAD, &isNonWeb);
+  if (NS_FAILED(rv) || !isNonWeb) {
+    return false;
+  }
+
+  return true;
+}
+
 void
 nsIDocument::ResetToURI(nsIURI* aURI,
                         nsILoadGroup* aLoadGroup,
                         nsIPrincipal* aPrincipal)
 {
   MOZ_ASSERT(aURI, "Null URI passed to ResetToURI");
 
   MOZ_LOG(gDocumentLeakPRLog, LogLevel::Debug,
@@ -3156,16 +3191,22 @@ nsIDocument::SetPrincipal(nsIPrincipal *
     bool isHTTPS;
     if (!uri || NS_FAILED(uri->SchemeIs("https", &isHTTPS)) ||
         isHTTPS) {
       mAllowDNSPrefetch = false;
     }
   }
   mNodeInfoManager->SetDocumentPrincipal(aNewPrincipal);
 
+  if (aNewPrincipal && PrincipalAllowsL10n(aNewPrincipal)) {
+    mPrincipalAllowsL10n = true;
+  } else {
+    mPrincipalAllowsL10n = false;
+  }
+
 #ifdef DEBUG
   // Validate that the docgroup is set correctly by calling its getter and
   // triggering its sanity check.
   //
   // If we're setting the principal to null, we don't want to perform the check,
   // as the document is entering an intermediate state where it does not have a
   // principal. It will be given another real principal shortly which we will
   // check. It's not unsafe to have a document which has a null principal in the
@@ -3350,16 +3391,121 @@ nsIDocument::GetAboutCapabilities(ErrorR
     mAboutCapabilities = new AboutCapabilities(jsImplObj, sgo);
   }
   RefPtr<AboutCapabilities> aboutCapabilities =
     static_cast<AboutCapabilities*>(mAboutCapabilities.get());
   return aboutCapabilities.forget();
 }
 
 bool
+nsDocument::DocumentSupportsL10n(JSContext* aCx, JSObject* aObject)
+{
+  return PrincipalAllowsL10n(nsContentUtils::SubjectPrincipal(aCx));
+}
+
+void
+nsIDocument::LocalizationLinkAdded(Element* aLinkElement)
+{
+  auto l10n = GetLocalization(true);
+  if (!l10n) {
+    return;
+  }
+
+  Element* parent = aLinkElement->GetParentElement();
+  if (!parent) {
+    return;
+  }
+
+  Element* head = GetHeadElement();
+  if (parent != head && !parent->NodeInfo()->Equals(nsGkAtoms::linkset, kNameSpaceID_XUL)) {
+    // TODO log a warning
+    return;
+  }
+
+  nsString href;
+  aLinkElement->GetAttr(kNameSpaceID_None, nsGkAtoms::href, href);
+  (*l10n)->AddResourceId(href);
+}
+
+void
+nsIDocument::LocalizationLinkRemoved(Element* aLinkElement)
+{
+  auto l10n = GetLocalization(true);
+  if (!l10n) {
+    return;
+  }
+
+  Element* parent = aLinkElement->GetParentElement();
+  if (!parent) {
+    return;
+  }
+
+  Element* head = GetHeadElement();
+  if (parent != head && !parent->NodeInfo()->Equals(nsGkAtoms::linkset, kNameSpaceID_XUL)) {
+    // TODO log a warning
+    return;
+  }
+
+  nsString href;
+  aLinkElement->GetAttr(kNameSpaceID_None, nsGkAtoms::href, href);
+  (*l10n)->RemoveResourceId(href);
+}
+
+mozilla::Maybe<RefPtr<DocumentL10n>>
+nsIDocument::GetLocalization(bool initialize)
+{
+  if (mDocumentL10n) {
+    return Some(mDocumentL10n);
+  } else if (initialize && mPrincipalAllowsL10n) {
+    mDocumentL10n = new DocumentL10n(this, mL10nResourceContainerParsed, mDOMParsed);
+    return Some(mDocumentL10n);
+  }
+  return Nothing();
+}
+
+void
+nsIDocument::OnL10nResourceContainerParsed()
+{
+  if (mL10nResourceContainerParsed) {
+    return;
+  }
+  auto l10n = GetLocalization(false);
+  if (l10n) {
+    (*l10n)->OnL10nResourceContainerParsed();
+  }
+  mL10nResourceContainerParsed = true;
+}
+
+void
+nsIDocument::OnDOMParsed()
+{
+  if (mDOMParsed) {
+    return;
+  }
+  auto l10n = GetLocalization(false);
+  if (l10n) {
+    (*l10n)->OnDOMParsed();
+  }
+  mDOMParsed = true;
+}
+
+already_AddRefed<DocumentL10n>
+nsIDocument::GetL10n(ErrorResult& aRv)
+{
+  auto l10nOrig = GetLocalization(true);
+  if (l10nOrig.isNothing()) {
+    aRv.Throw(NS_ERROR_FAILURE);
+    return nullptr;
+  }
+
+  RefPtr<DocumentL10n> l10n(l10nOrig.value());
+  return l10n.forget();
+}
+
+bool
 nsDocument::IsElementAnimateEnabled(JSContext* aCx, JSObject* /*unused*/)
 {
   MOZ_ASSERT(NS_IsMainThread());
 
   return nsContentUtils::IsSystemCaller(aCx) ||
          nsContentUtils::AnimationsAPICoreEnabled() ||
          nsContentUtils::AnimationsAPIElementAnimateEnabled();
 }
@@ -5103,16 +5249,19 @@ nsIDocument::DispatchContentLoadedEvents
 
   // Unpin references to preloaded images
   mPreloadingImages.Clear();
 
   // DOM manipulation after content loaded should not care if the element
   // came from the preloader.
   mPreloadedPreconnects.Clear();
 
+  // DOM has been parsed by now, we can start localization
+  OnDOMParsed();
+
   if (mTiming) {
     mTiming->NotifyDOMContentLoadedStart(nsIDocument::GetDocumentURI());
   }
 
   // Dispatch observer notification to notify observers document is interactive.
   nsCOMPtr<nsIObserverService> os = mozilla::services::GetObserverService();
   if (os) {
     nsIPrincipal* principal = NodePrincipal();
--- a/dom/base/nsDocument.h
+++ b/dom/base/nsDocument.h
@@ -156,16 +156,17 @@ public:
                                      nsISupports* aContainer,
                                      nsIStreamListener **aDocListener,
                                      bool aReset = true,
                                      nsIContentSink* aContentSink = nullptr) override = 0;
 
   virtual void StopDocumentLoad() override;
 
   static bool CallerIsTrustedAboutPage(JSContext* aCx, JSObject* aObject);
+  static bool DocumentSupportsL10n(JSContext* aCx, JSObject* aObject);
   static bool IsElementAnimateEnabled(JSContext* aCx, JSObject* aObject);
   static bool IsWebAnimationsEnabled(JSContext* aCx, JSObject* aObject);
   static bool IsWebAnimationsEnabled(mozilla::dom::CallerType aCallerType);
 
   virtual void EndUpdate() override;
   virtual void BeginLoad() override;
   virtual void EndLoad() override;
 
--- a/dom/base/nsIDocument.h
+++ b/dom/base/nsIDocument.h
@@ -143,16 +143,17 @@ class Attr;
 class BoxObject;
 class ClientInfo;
 class ClientState;
 class CDATASection;
 class Comment;
 struct CustomElementDefinition;
 class DocGroup;
 class DocumentFragment;
+class DocumentL10n;
 class DocumentTimeline;
 class DocumentType;
 class DOMImplementation;
 class DOMIntersectionObserver;
 class DOMStringList;
 class Element;
 struct ElementCreationOptions;
 class Event;
@@ -3134,16 +3135,22 @@ public:
   void GetInputEncoding(nsAString& aInputEncoding) const;
   already_AddRefed<mozilla::dom::Location> GetLocation() const;
   void GetReferrer(nsAString& aReferrer) const;
   void GetLastModified(nsAString& aLastModified) const;
   void GetReadyState(nsAString& aReadyState) const;
 
   already_AddRefed<mozilla::dom::AboutCapabilities> GetAboutCapabilities(
     ErrorResult& aRv);
+  void LocalizationLinkAdded(Element* aLinkElement);
+  void LocalizationLinkRemoved(Element* aLinkElement);
+  mozilla::Maybe<RefPtr<mozilla::dom::DocumentL10n>> GetLocalization(bool initialize);
+  void OnL10nResourceContainerParsed();
+  void OnDOMParsed();
+  already_AddRefed<mozilla::dom::DocumentL10n> GetL10n(ErrorResult& aRv);
 
   void GetTitle(nsAString& aTitle);
   void SetTitle(const nsAString& aTitle, mozilla::ErrorResult& rv);
   void GetDir(nsAString& aDirection) const;
   void SetDir(const nsAString& aDirection);
   nsIHTMLCollection* Images();
   nsIHTMLCollection* Embeds();
   nsIHTMLCollection* Plugins()
@@ -3808,16 +3815,20 @@ protected:
   // focus has never occurred then mLastFocusTime.IsNull() will be true.
   mozilla::TimeStamp mLastFocusTime;
 
   mozilla::EventStates mDocumentState;
 
   RefPtr<mozilla::dom::Promise> mReadyForIdle;
 
   RefPtr<mozilla::dom::AboutCapabilities> mAboutCapabilities;
+  RefPtr<mozilla::dom::DocumentL10n> mDocumentL10n;
+  bool mL10nResourceContainerParsed : 1;
+  bool mDOMParsed : 1;
+  bool mPrincipalAllowsL10n : 1;
 
   // True if BIDI is enabled.
   bool mBidiEnabled : 1;
   // True if a MathML element has ever been owned by this document.
   bool mMathMLEnabled : 1;
 
   // True if this document is the initial document for a window.  This should
   // basically be true only for documents that exist in newly-opened windows or
--- a/dom/html/HTMLBodyElement.cpp
+++ b/dom/html/HTMLBodyElement.cpp
@@ -294,16 +294,21 @@ nsresult
 HTMLBodyElement::BindToTree(nsIDocument* aDocument, nsIContent* aParent,
                             nsIContent* aBindingParent,
                             bool aCompileEventHandlers)
 {
   nsresult rv = nsGenericHTMLElement::BindToTree(aDocument, aParent,
                                                  aBindingParent,
                                                  aCompileEventHandlers);
   NS_ENSURE_SUCCESS(rv, rv);
+
+  // We now have a body, if l10n resources were to be loaded, it would happen
+  // already.
+  OwnerDoc()->OnL10nResourceContainerParsed();
+
   return mAttrsAndChildren.ForceMapped(this, OwnerDoc());
 }
 
 nsresult
 HTMLBodyElement::AfterSetAttr(int32_t aNameSpaceID, nsAtom* aName,
                               const nsAttrValue* aValue,
                               const nsAttrValue* aOldValue,
                               nsIPrincipal* aSubjectPrincipal,
--- a/dom/html/HTMLLinkElement.cpp
+++ b/dom/html/HTMLLinkElement.cpp
@@ -144,16 +144,23 @@ HTMLLinkElement::BindToTree(nsIDocument*
   if (IsInComposedDoc()) {
     TryDNSPrefetchOrPreconnectOrPrefetchOrPreloadOrPrerender();
   }
 
   void (HTMLLinkElement::*update)() = &HTMLLinkElement::UpdateStyleSheetInternal;
   nsContentUtils::AddScriptRunner(
     NewRunnableMethod("dom::HTMLLinkElement::BindToTree", this, update));
 
+  if (aDocument) {
+    nsAutoString rel;
+    GetAttr(kNameSpaceID_None, nsGkAtoms::rel, rel);
+    if (rel.EqualsLiteral("localization"))
+      aDocument->LocalizationLinkAdded(this);
+  }
+
   CreateAndDispatchEvent(aDocument, NS_LITERAL_STRING("DOMLinkAdded"));
 
   return rv;
 }
 
 void
 HTMLLinkElement::LinkAdded()
 {
@@ -184,16 +191,23 @@ HTMLLinkElement::UnbindFromTree(bool aDe
   // from the parser.
   nsCOMPtr<nsIDocument> oldDoc = GetUncomposedDoc();
 
   // Check for a ShadowRoot because link elements are inert in a
   // ShadowRoot.
   ShadowRoot* oldShadowRoot = GetBindingParent() ?
     GetBindingParent()->GetShadowRoot() : nullptr;
 
+  if (oldDoc) {
+    nsAutoString rel;
+    GetAttr(kNameSpaceID_None, nsGkAtoms::rel, rel);
+    if (rel.EqualsLiteral("localization"))
+      oldDoc->LocalizationLinkRemoved(this);
+  }
+
   CreateAndDispatchEvent(oldDoc, NS_LITERAL_STRING("DOMLinkRemoved"));
   nsGenericHTMLElement::UnbindFromTree(aDeep, aNullParent);
 
   Unused << UpdateStyleSheetInternal(oldDoc, oldShadowRoot);
 }
 
 bool
 HTMLLinkElement::ParseAttribute(int32_t aNamespaceID,
--- a/dom/webidl/Document.webidl
+++ b/dom/webidl/Document.webidl
@@ -497,16 +497,20 @@ partial interface Document {
   readonly attribute FlashClassification documentFlashClassification;
 };
 
 // Allows about: pages to query aboutCapabilities
 partial interface Document {
   [Throws, Func="nsDocument::CallerIsTrustedAboutPage"] readonly attribute AboutCapabilities aboutCapabilities;
 };
 
+partial interface Document {
+  [Throws, Func="nsDocument::DocumentSupportsL10n"] readonly attribute DocumentL10n l10n;
+};
+
 Document implements XPathEvaluator;
 Document implements GlobalEventHandlers;
 Document implements DocumentAndElementEventHandlers;
 Document implements TouchEventHandlers;
 Document implements ParentNode;
 Document implements OnErrorEventHandlerForNodes;
 Document implements GeometryUtils;
 Document implements FontFaceSource;
new file mode 100644
--- /dev/null
+++ b/dom/webidl/DocumentL10n.webidl
@@ -0,0 +1,22 @@
+/* -*- 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/.
+ */
+
+dictionary L10nKey {
+  required DOMString id;
+
+  object? args = null;
+};
+
+[NoInterfaceObject]
+interface DocumentL10n {
+  [Throws] void setAttributes(Element aElement, DOMString aId, optional object aArgs);
+
+  [Throws] Promise<DOMString> formatValue(DOMString aId, optional object aArgs);
+
+  [Throws] Promise<sequence<DOMString>> formatValues(sequence<L10nKey> aKeys);
+
+  [Throws] readonly attribute Promise<any> ready;
+};
--- a/dom/webidl/moz.build
+++ b/dom/webidl/moz.build
@@ -455,16 +455,17 @@ WEBIDL_FILES = [
     'DataTransferItemList.webidl',
     'DecoderDoctorNotification.webidl',
     'DedicatedWorkerGlobalScope.webidl',
     'DelayNode.webidl',
     'DeviceMotionEvent.webidl',
     'Directory.webidl',
     'Document.webidl',
     'DocumentFragment.webidl',
+    'DocumentL10n.webidl',
     'DocumentOrShadowRoot.webidl',
     'DocumentTimeline.webidl',
     'DocumentType.webidl',
     'DOMError.webidl',
     'DOMException.webidl',
     'DOMImplementation.webidl',
     'DOMMatrix.webidl',
     'DOMParser.webidl',
--- a/dom/xul/XULDocument.cpp
+++ b/dom/xul/XULDocument.cpp
@@ -74,16 +74,17 @@
 #include "nsIObserverService.h"
 #include "nsNodeUtils.h"
 #include "nsIDocShellTreeOwner.h"
 #include "nsIXULWindow.h"
 #include "nsXULPopupManager.h"
 #include "nsCCUncollectableMarker.h"
 #include "nsURILoader.h"
 #include "mozilla/BasicEvents.h"
+#include "mozilla/dom/DocumentL10n.h"
 #include "mozilla/dom/Element.h"
 #include "mozilla/dom/NodeInfoInlines.h"
 #include "mozilla/dom/ProcessingInstruction.h"
 #include "mozilla/dom/ScriptSettings.h"
 #include "mozilla/dom/XULDocumentBinding.h"
 #include "mozilla/EventDispatcher.h"
 #include "mozilla/LoadInfo.h"
 #include "mozilla/Preferences.h"
@@ -1405,16 +1406,19 @@ XULDocument::AddElementToDocumentPost(El
     if (aElement == GetRootElement()) {
         ResetDocumentDirection();
     }
 
     // We need to pay special attention to the keyset tag to set up a listener
     if (aElement->NodeInfo()->Equals(nsGkAtoms::keyset, kNameSpaceID_XUL)) {
         // Create our XUL key listener and hook it up.
         nsXBLService::AttachGlobalKeyHandler(aElement);
+    } else if (aElement->NodeInfo()->Equals(nsGkAtoms::linkset, kNameSpaceID_XUL)) {
+        // If l10n resources were to be loaded, it would happen already.
+        OnL10nResourceContainerParsed();
     }
 
     return NS_OK;
 }
 
 nsresult
 XULDocument::AddSubtreeToDocument(nsIContent* aContent)
 {
@@ -2707,16 +2711,22 @@ XULDocument::DoneWalking()
         // the |if (!mDocumentLoaded)| check above and since
         // mInitialLayoutComplete will be false will follow the else branch
         // there too.  See the big comment there for how such reentry can
         // happen.
         mDocumentLoaded = true;
 
         NotifyPossibleTitleChange(false);
 
+
+        // We parsed the whole document.
+        // If l10n resources were to be loaded, it would happen already.
+        OnL10nResourceContainerParsed();
+        OnDOMParsed();
+
         nsContentUtils::DispatchTrustedEvent(
             this,
             static_cast<nsIDocument*>(this),
             NS_LITERAL_STRING("MozBeforeInitialXULLayout"),
             true,
             false);
 
         // Before starting layout, check whether we're a toplevel chrome
new file mode 100644
--- /dev/null
+++ b/intl/l10n/DocumentL10n.cpp
@@ -0,0 +1,218 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* 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/. */
+
+#include "mozilla/dom/DocumentL10n.h"
+#include "mozilla/dom/DocumentL10nBinding.h"
+#include "nsQueryObject.h"
+#include "mozilla/dom/Promise.h"
+#include "nsISupports.h"
+#include "nsContentUtils.h"
+
+namespace mozilla {
+namespace dom {
+
+NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(DocumentL10n, mDocument, mDocumentLocalization)
+
+NS_IMPL_CYCLE_COLLECTING_ADDREF(DocumentL10n)
+NS_IMPL_CYCLE_COLLECTING_RELEASE(DocumentL10n)
+
+NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(DocumentL10n)
+  NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY
+  NS_INTERFACE_MAP_ENTRY(nsISupports)
+NS_INTERFACE_MAP_END
+
+/**
+ * This can get lazily initialized at multiple points during the lifecycle
+ * of the document. It can get initialized when the first link is registered,
+ * or while document is still being parsed, or much later.
+ *
+ * For that reason we initialize the `DocumentLocalization` with information
+ * about those two states, and if either of them is `false` at the time
+ * of initialization, the corresponding method is expected to be fired
+ * by the parsed later.
+ */
+DocumentL10n::DocumentL10n(nsIDocument* aDocument, bool aL10nResourceContainerParsed, bool aDOMParsed)
+  : mDocument(aDocument)
+  , mDocumentLocalization(nullptr)
+  , mL10nResourceContainerParsed(aL10nResourceContainerParsed)
+  , mDOMParsed(aDOMParsed)
+{
+  printf("DocumentL10n::DocumentL10n\n");
+  if (aL10nResourceContainerParsed) {
+    printf("DocumentL10n::DocumentL10n L10nResourceContainerParsed\n");
+  }
+  if (aDOMParsed) {
+    printf("DocumentL10n::DocumentL10n DOMParsed\n");
+  }
+  nsresult rv;
+  nsCOMPtr<mozIDocumentLocalization> docL10n = do_CreateInstance("@mozilla.org/intl/documentlocalization;1", &rv);
+  if (NS_FAILED(rv)) {
+    return;
+  }
+
+  mDocumentLocalization = docL10n;
+  mDocumentLocalization->Init(mDocument, mL10nResourceContainerParsed, mDOMParsed);
+}
+
+DocumentL10n::~DocumentL10n()
+{
+  mDocumentLocalization = nullptr;
+}
+
+void
+DocumentL10n::OnDOMParsed()
+{
+  printf("DocumentL10n::OnDOMParsed\n");
+  if (!mDOMParsed) {
+    if (nsContentUtils::IsSafeToRunScript()) {
+      mDocumentLocalization->OnDOMParsed();
+    } else {
+      nsContentUtils::AddScriptRunner(
+          NewRunnableMethod("mozIDocumentLocalization::OnDOMParsed",
+            mDocumentLocalization,
+            &mozIDocumentLocalization::OnDOMParsed));
+    }
+    mDOMParsed = true;
+  }
+}
+
+void
+DocumentL10n::OnL10nResourceContainerParsed()
+{
+  printf("DocumentL10n::OnL10nResourceContainerParsed\n");
+  if (!mL10nResourceContainerParsed) {
+    if (nsContentUtils::IsSafeToRunScript()) {
+      mDocumentLocalization->OnL10nResourceContainerParsed();
+    } else {
+      nsContentUtils::AddScriptRunner(
+          NewRunnableMethod("mozIDocumentLocalization::OnL10nResourceContainerParsed",
+            mDocumentLocalization,
+            &mozIDocumentLocalization::OnL10nResourceContainerParsed));
+    }
+    mL10nResourceContainerParsed = true;
+  }
+}
+
+JSObject*
+DocumentL10n::WrapObject(JSContext* aCx, JS::Handle<JSObject*> aGivenProto)
+{
+  return DocumentL10nBinding::Wrap(aCx, this, aGivenProto);
+}
+
+void
+DocumentL10n::AddResourceId(const nsAString& aResourceId)
+{
+  //XXX: If I uncomment it, the Preferences pane doesn't load resources, seems
+  //     like the NewRunnableMethod doesn't work :(
+  printf("DocumentL10n::AddResourceId\n");
+  /* if (nsContentUtils::IsSafeToRunScript()) { */
+    printf("DocumentL10n::AddResourceId IsSafeToRunScript\n");
+    mDocumentLocalization->AddResourceId(aResourceId);
+  /* } else { */
+  /*   printf("DocumentL10n::AddResourceId IsNotSafeToRunScript\n"); */
+  /*   nsContentUtils::AddScriptRunner( */
+  /*       NewRunnableMethod<const nsAString&>("mozIDocumentLocalization::AddResourceId", */
+  /*         mDocumentLocalization, */
+  /*         &mozIDocumentLocalization::AddResourceId, */
+  /*         aResourceId)); */
+  /* } */
+}
+
+void
+DocumentL10n::RemoveResourceId(const nsAString& aResourceId)
+{
+  /* if (nsContentUtils::IsSafeToRunScript()) { */
+    mDocumentLocalization->RemoveResourceId(aResourceId);
+  /* } else { */
+  /*   nsContentUtils::AddScriptRunner( */
+  /*       NewRunnableMethod<const nsAString&>("mozIDocumentLocalization::RemoveResourceId", */
+  /*         mDocumentLocalization, */
+  /*         &mozIDocumentLocalization::RemoveResourceId, */
+  /*         aResourceId)); */
+  /* } */
+}
+
+void
+DocumentL10n::SetAttributes(JSContext* cx, Element& aElement, const nsAString& aId, const Optional<JS::Handle<JSObject*>>& aArgs, ErrorResult& aRv)
+{
+  JS::RootedValue args(cx);
+
+  if (aArgs.WasPassed()) {
+    args = JS::ObjectValue(*aArgs.Value());
+  } else {
+    args = JS::UndefinedValue();
+  }
+
+  nsresult rv = mDocumentLocalization->SetAttributes(&aElement, aId, args);
+
+  if (NS_FAILED(rv)) {
+    aRv.Throw(rv);
+  }
+}
+
+already_AddRefed<Promise>
+DocumentL10n::FormatValue(JSContext* cx, const nsAString& aId, const Optional<JS::Handle<JSObject*>>& aArgs, ErrorResult& aRv)
+{
+  JS::RootedValue args(cx);
+
+  if (aArgs.WasPassed()) {
+    args = JS::ObjectValue(*aArgs.Value());
+  } else {
+    args = JS::UndefinedValue();
+  }
+
+  RefPtr<Promise> promise;
+  nsresult rv = mDocumentLocalization->FormatValue(aId, args, getter_AddRefs(promise));
+  if (NS_FAILED(rv)) {
+    aRv.Throw(rv);
+    return nullptr;
+  }
+
+  return promise.forget();
+}
+
+already_AddRefed<Promise>
+DocumentL10n::FormatValues(JSContext* cx, const Sequence<L10nKey>& aKeys, ErrorResult& aRv)
+{
+  nsTArray<JS::HandleValue> jsKeys;
+  jsKeys.SetCapacity(aKeys.Length());
+
+  for (auto& key : aKeys) {
+    JS::RootedValue jsKey(cx);
+    if (!ToJSValue(cx, key, &jsKey)) {
+      aRv.Throw(NS_ERROR_UNEXPECTED);
+      return nullptr;
+    }
+
+    jsKeys.AppendElement(jsKey);
+  }
+
+  RefPtr<Promise> promise;
+  nsresult rv = mDocumentLocalization->FormatValues(jsKeys.Elements(), jsKeys.Length(), getter_AddRefs(promise));
+  if (NS_FAILED(rv)) {
+    aRv.Throw(rv);
+    return nullptr;
+  }
+
+  return promise.forget();
+}
+
+already_AddRefed<Promise>
+DocumentL10n::GetReady(ErrorResult& aRv)
+{
+  RefPtr<Promise> promise;
+
+  nsresult rv = mDocumentLocalization->GetReady(getter_AddRefs(promise));
+  if (NS_FAILED(rv)) {
+    aRv.Throw(rv);
+    return nullptr;
+  }
+
+  return promise.forget();
+}
+
+} // namespace dom
+} // namespace mozilla
new file mode 100644
--- /dev/null
+++ b/intl/l10n/DocumentL10n.h
@@ -0,0 +1,67 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* 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/. */
+
+#ifndef mozilla_dom_DocumentL10n_h
+#define mozilla_dom_DocumentL10n_h
+
+#include "js/TypeDecls.h"
+#include "mozilla/Attributes.h"
+#include "mozilla/ErrorResult.h"
+#include "mozilla/dom/BindingDeclarations.h"
+#include "nsCycleCollectionParticipant.h"
+#include "nsWrapperCache.h"
+#include "nsIDocument.h"
+#include "mozIDocumentLocalization.h"
+
+namespace mozilla {
+namespace dom {
+
+class Element;
+class Promise;
+struct L10nKey;
+
+class DocumentL10n final : public nsISupports,
+                           public nsWrapperCache
+{
+public:
+  NS_DECL_CYCLE_COLLECTING_ISUPPORTS
+  NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS(DocumentL10n)
+
+public:
+  explicit DocumentL10n(nsIDocument* aDocument, bool aL10nResourceContainerParsed, bool aDOMParsed);
+
+protected:
+  virtual ~DocumentL10n();
+
+  nsCOMPtr<nsIDocument> mDocument;
+  nsCOMPtr<mozIDocumentLocalization> mDocumentLocalization;
+  bool mL10nResourceContainerParsed;
+  bool mDOMParsed;
+
+public:
+  void OnDOMParsed();
+  void OnL10nResourceContainerParsed();
+
+  nsIDocument* GetParentObject() const { return mDocument; };
+
+  virtual JSObject* WrapObject(JSContext* aCx, JS::Handle<JSObject*> aGivenProto) override;
+
+  void AddResourceId(const nsAString& aResourceId);
+
+  void RemoveResourceId(const nsAString& aResourceId);
+
+  void SetAttributes(JSContext* cx, Element& aElement, const nsAString& aId, const Optional<JS::Handle<JSObject*>>& aArgs, ErrorResult& aRv);
+
+  already_AddRefed<Promise> FormatValue(JSContext* cx, const nsAString& aId, const Optional<JS::Handle<JSObject*>>& aArgs, ErrorResult& aRv);
+
+  already_AddRefed<Promise> FormatValues(JSContext* cx, const Sequence<L10nKey>& aKeys, ErrorResult& aRv);
+  already_AddRefed<Promise> GetReady(ErrorResult& aRv);
+};
+
+} // namespace dom
+} // namespace mozilla
+
+#endif // mozilla_dom_DocumentL10n_h
--- a/intl/l10n/moz.build
+++ b/intl/l10n/moz.build
@@ -17,16 +17,28 @@ XPIDL_SOURCES += [
 
 XPIDL_MODULE = 'locale'
 
 EXTRA_COMPONENTS += [
     'mozDocumentLocalization.js',
     'mozDocumentLocalization.manifest',
 ]
 
+EXPORTS.mozilla.dom += [
+    'DocumentL10n.h',
+]
+
+UNIFIED_SOURCES += [
+    'DocumentL10n.cpp',
+]
+
+LOCAL_INCLUDES += [
+    '/dom/base',
+]
+
 XPCSHELL_TESTS_MANIFESTS += ['test/xpcshell.ini']
 
 MOCHITEST_CHROME_MANIFESTS += ['test/chrome.ini']
 
 JAR_MANIFESTS += ['jar.mn']
 
 SPHINX_TREES['l10n'] = 'docs'
 
--- a/intl/l10n/test/chrome.ini
+++ b/intl/l10n/test/chrome.ini
@@ -9,8 +9,18 @@
 [dom/test_domloc_translateRoots.html]
 [dom/test_domloc_mutations.html]
 [dom/test_domloc_overlay.html]
 [dom/test_domloc_overlay_repeated.html]
 [dom/test_domloc_overlay_missing_all.html]
 [dom/test_domloc_overlay_missing_children.html]
 [dom/test_domloc_overlay_sanitized.html]
 [dom/test_domloc.xul]
+
+[document_l10n/xul/test_api_before_links.xul]
+[document_l10n/xul/test_dom_loc.xul]
+[document_l10n/xul/test_lazy_resource_injection.xul]
+[document_l10n/xul/test_lazy_webcomponent.xul]
+[document_l10n/xul/test_no_loc.xul]
+[document_l10n/xul/test_no_loc_api_calls.xul]
+
+[document_l10n/html/test_dom_loc.html]
+[document_l10n/html/test_no_loc.html]
new file mode 100644
--- /dev/null
+++ b/intl/l10n/test/document_l10n/html/test_dom_loc.html
@@ -0,0 +1,23 @@
+<html>
+  <head>
+    <title>Testing that the DOM is localized at firstPaint</title>
+    <link rel="stylesheet" type="text/css"
+          href="chrome://mochikit/content/tests/SimpleTest/test.css" />
+    <script type="application/javascript"
+            src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js" /></script>
+    <script type="application/javascript">
+      SimpleTest.waitForExplicitFinish();
+
+      document.l10n.ready.then(() => {
+        // Test that it's before first paint
+        const label = document.getElementById("label");
+        isnot(label.textContent, "");
+        SimpleTest.finish();
+      });
+    </script>
+    <link rel="localization" href="browser/preferences/preferences.ftl"/>
+  </head>
+  <body>
+    <label id="label" data-l10n-id="pane-general-title"/>
+  </body>
+</html>
new file mode 100644
--- /dev/null
+++ b/intl/l10n/test/document_l10n/html/test_no_loc.html
@@ -0,0 +1,23 @@
+<html>
+  <head>
+    <title>
+      Testing that document.l10n methods resolve even when there are no resources
+    </title>
+    <link rel="stylesheet" type="text/css"
+          href="chrome://mochikit/content/tests/SimpleTest/test.css" />
+    <script type="application/javascript"
+            src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js" /></script>
+    <script type="application/javascript">
+      SimpleTest.waitForExplicitFinish();
+
+      async function test() {
+        let value = await document.l10n.formatValue("pane-general-title");
+        is(value, undefined);
+        SimpleTest.finish();
+      }
+      test();
+    </script>
+  </head>
+  <body>
+  </body>
+</html>
new file mode 100644
--- /dev/null
+++ b/intl/l10n/test/document_l10n/xul/test_api_before_links.xul
@@ -0,0 +1,27 @@
+<?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"?>
+
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+        xmlns:html="http://www.w3.org/1999/xhtml"
+        title="Testing that document.l10n methods resolve once links are loaded">
+
+  <script type="application/javascript"
+          src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js" />
+  <script type="application/javascript">
+  <![CDATA[
+    SimpleTest.waitForExplicitFinish();
+
+    async function test() {
+      let value = await document.l10n.formatValue("pane-general-title");
+      isnot(value, undefined);
+      SimpleTest.finish();
+    }
+    test();
+  ]]>
+  </script>
+  <linkset>
+    <html:link rel="localization" href="browser/preferences/preferences.ftl"/>
+  </linkset>
+</window>
new file mode 100644
--- /dev/null
+++ b/intl/l10n/test/document_l10n/xul/test_dom_loc.xul
@@ -0,0 +1,31 @@
+<?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"?>
+
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+        xmlns:html="http://www.w3.org/1999/xhtml"
+        title="Testing that the DOM is localized at firstPaint">
+  <label id="label-pre" data-l10n-id="pane-home-title"/>
+
+  <script type="application/javascript"
+          src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js" />
+  <script type="application/javascript">
+  <![CDATA[
+    SimpleTest.waitForExplicitFinish();
+
+    document.l10n.ready.then(() => {
+      // Test that it's before first paint
+      const label1 = document.getElementById("label-pre");
+      const label2 = document.getElementById("label-post");
+      isnot(label1.textContent, "");
+      isnot(label2.textContent, "");
+      SimpleTest.finish();
+    });
+  ]]>
+  </script>
+  <linkset>
+    <html:link rel="localization" href="browser/preferences/preferences.ftl"/>
+  </linkset>
+  <label id="label-post" data-l10n-id="pane-general-title"/>
+</window>
new file mode 100644
--- /dev/null
+++ b/intl/l10n/test/document_l10n/xul/test_lazy_resource_injection.xul
@@ -0,0 +1,52 @@
+<?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"?>
+
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+        xmlns:html="http://www.w3.org/1999/xhtml"
+        title="Testing that a resource injected late becomes available">
+  <script type="application/javascript"
+          src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js" />
+  <script type="application/javascript">
+  <![CDATA[
+    SimpleTest.waitForExplicitFinish();
+
+    function injectResource() {
+      let link = document.createElementNS("http://www.w3.org/1999/xhtml", "link");
+      link.setAttribute("rel", "localization");
+      link.setAttribute("href", "browser/preferences/colors.ftl");
+      let linkset = document.querySelector("linkset");
+      linkset.appendChild(link);
+    }
+
+    function removeResource() {
+      let link = document.querySelector("link[href='browser/preferences/colors.ftl']");
+      link.parentNode.removeChild(link);
+    }
+
+    document.l10n.ready.then(async () => {
+      // Test that it's before first paint
+      const label1 = document.getElementById("label");
+      isnot(label1.textContent, "");
+
+      injectResource();
+
+      let value = await document.l10n.formatValue("colors-links-header");
+      isnot(value, undefined);
+
+      removeResource();
+
+      let value2 = await document.l10n.formatValue("colors-links-header");
+      is(value2, undefined);
+
+
+      SimpleTest.finish();
+    });
+  ]]>
+  </script>
+  <linkset>
+    <html:link rel="localization" href="browser/preferences/preferences.ftl"/>
+  </linkset>
+  <label id="label" data-l10n-id="pane-general-title"/>
+</window>
new file mode 100644
--- /dev/null
+++ b/intl/l10n/test/document_l10n/xul/test_lazy_webcomponent.xul
@@ -0,0 +1,78 @@
+<?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"?>
+
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+        xmlns:html="http://www.w3.org/1999/xhtml"
+        title="Testing that a lazily injected web component can be localized">
+  <!-- <linkset> -->
+  <!--   <html:link rel="localization" href="browser/preferences/translation.ftl" /> -->
+  <!-- </linkset> -->
+  <script type="application/javascript"
+          src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js" />
+  <script type="application/javascript">
+  <![CDATA[
+    function waitForMutation(target, opts, cb) {
+      return new Promise((resolve) => {
+        let observer = new MutationObserver(() => {
+          if (!cb || cb(target)) {
+            observer.disconnect();
+            resolve();
+          }
+        });
+        observer.observe(target, opts);
+      });
+    }
+
+    function waitForMessageChange(element, cb, opts = { childList: true }) {
+      return waitForMutation(element, opts, cb);
+    }
+
+    SimpleTest.waitForExplicitFinish();
+
+    function appendL10nResource(id) {
+      let linkset = document.querySelector("linkset");
+      if (!linkset) {
+        linkset = document.createElement("linkset");
+        document.documentElement.appendChild(linkset);
+      }
+      let link = document.createElementNS("http://www.w3.org/1999/xhtml", "link");
+      link.setAttribute("rel", "localization");
+      link.setAttribute("href", id);
+      linkset.appendChild(link);
+    }
+
+    function registerCE() {
+      customElements.define("foo-bar", class MyElement extends XULElement {
+        connectedCallback() {
+          this.appendChild(MozXULElement.parseXULToFragment(
+            `<label data-l10n-id='translation-sites-disabled-desc' />`));
+        }
+      });
+    }
+
+    function injectCE() {
+      document.documentElement.append(document.createElement("foo-bar"));
+    }
+
+    addLoadEvent(async () => {
+      await new Promise((resolve) => setTimeout(resolve, 500));
+
+      appendL10nResource("browser/preferences/translation.ftl");
+
+      let expectedText = await document.l10n.formatValue("translation-sites-disabled-desc"); 
+      isnot(expectedText, undefined);
+
+      registerCE();
+      injectCE();
+
+      let elem = document.querySelector("label");
+      await waitForMessageChange(elem);
+      is(elem.textContent, expectedText);
+
+      SimpleTest.finish();
+    });
+  ]]>
+  </script>
+</window>
new file mode 100644
--- /dev/null
+++ b/intl/l10n/test/document_l10n/xul/test_no_loc.xul
@@ -0,0 +1,23 @@
+<?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"?>
+
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+        title="Testing that document.l10n methods resolve even when there are no resources">
+
+  <script type="application/javascript"
+          src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js" />
+  <script type="application/javascript">
+  <![CDATA[
+    SimpleTest.waitForExplicitFinish();
+
+    async function test() {
+      let value = await document.l10n.formatValue("pane-general-title");
+      is(value, undefined);
+      SimpleTest.finish();
+    }
+    test();
+  ]]>
+  </script>
+</window>
new file mode 100644
--- /dev/null
+++ b/intl/l10n/test/document_l10n/xul/test_no_loc_api_calls.xul
@@ -0,0 +1,22 @@
+<?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"?>
+
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+        title="Testing that API resolve if called late with no linkset">
+
+  <script type="application/javascript"
+          src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js" />
+  <script type="application/javascript">
+  <![CDATA[
+    SimpleTest.waitForExplicitFinish();
+
+    window.onload = async () => {
+      await document.l10n.ready;
+      is(1, 1);
+      SimpleTest.finish();
+    };
+  ]]>
+  </script>
+</window>
--- a/xpcom/ds/nsGkAtomList.h
+++ b/xpcom/ds/nsGkAtomList.h
@@ -543,16 +543,17 @@ GK_ATOM(left, "left")
 GK_ATOM(leftmargin, "leftmargin")
 GK_ATOM(legend, "legend")
 GK_ATOM(length, "length")
 GK_ATOM(letterValue, "letter-value")
 GK_ATOM(level, "level")
 GK_ATOM(li, "li")
 GK_ATOM(line, "line")
 GK_ATOM(link, "link")
+GK_ATOM(linkset, "linkset")
 //GK_ATOM(list, "list")  # "list" is present below
 GK_ATOM(listbox, "listbox")
 GK_ATOM(listboxbody, "listboxbody")
 GK_ATOM(listcell, "listcell")
 GK_ATOM(listcol, "listcol")
 GK_ATOM(listcols, "listcols")
 GK_ATOM(listener, "listener")
 GK_ATOM(listhead, "listhead")