Bug 1509609 - Provide a sync version of generateBundles for L10nregistry. r=mossop
authorZibi Braniecki <zbraniecki@mozilla.com>
Fri, 11 Jan 2019 00:19:37 +0000
changeset 453423 7d8101dcf03a2bfb19b5511716100d6014c093f8
parent 453422 02cd44e39566b22dc5c02f031c02045aca2a4393
child 453424 f8e2d2010c07418ea0916bb6a5679e872958d6e9
push id35357
push usernerli@mozilla.com
push dateFri, 11 Jan 2019 21:54:07 +0000
treeherdermozilla-central@0ce024c91511 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmossop
bugs1509609
milestone66.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1509609 - Provide a sync version of generateBundles for L10nregistry. r=mossop Differential Revision: https://phabricator.services.mozilla.com/D13166
intl/l10n/L10nRegistry.jsm
intl/l10n/test/test_l10nregistry_sync.js
intl/l10n/test/xpcshell.ini
--- a/intl/l10n/L10nRegistry.jsm
+++ b/intl/l10n/L10nRegistry.jsm
@@ -73,16 +73,22 @@ const isParentProcess = appinfo.processT
  *   }
  *
  * This allows the localization API to consume the FluentBundle and lazily fallback
  * on the next in case of a missing string or error.
  *
  * If during the life-cycle of the app a new source is added, the generator can be called again
  * and will produce a new set of permutations placing the language pack provided resources
  * at the top.
+ *
+ * Notice: L10nRegistry is primarily an asynchronous API, but
+ * it does provide a synchronous version of it's main method
+ * for use by the `LocalizationSync` class.
+ * This API should be only used in very specialized cases and
+ * the uses should be reviewed by the toolkit owner/peer.
  */
 class L10nRegistryService {
   constructor() {
     this.sources = new Map();
 
     if (!isParentProcess) {
       this._setSourcesFromSharedData();
       Services.cpmm.sharedData.addEventListener("change", this);
@@ -96,16 +102,19 @@ class L10nRegistryService {
       }
     }
   }
 
   /**
    * Based on the list of requested languages and resource Ids,
    * this function returns an lazy iterator over message context permutations.
    *
+   * Notice: Any changes to this method should be copied
+   * to the `generateBundlesSync` equivalent below.
+   *
    * @param {Array} requestedLangs
    * @param {Array} resourceIds
    * @returns {AsyncIterator<FluentBundle>}
    */
   async* generateBundles(requestedLangs, resourceIds) {
     const sourcesOrder = Array.from(this.sources.keys()).reverse();
     const pseudoNameFromPref = Services.prefs.getStringPref("intl.l10n.pseudo", "");
     for (const locale of requestedLangs) {
@@ -121,16 +130,48 @@ class L10nRegistryService {
           bundle.addResource(data);
         }
         yield bundle;
       }
     }
   }
 
   /**
+   * This is a synchronous version of the `generateBundles`
+   * method and should stay completely in sync with it at all
+   * times except of the async/await changes.
+   *
+   * Notice: This method should be avoided at all costs
+   * You can think of it similarly to a synchronous XMLHttpRequest.
+   *
+   * @param {Array} requestedLangs
+   * @param {Array} resourceIds
+   * @returns {Iterator<FluentBundle>}
+   */
+  * generateBundlesSync(requestedLangs, resourceIds) {
+    const sourcesOrder = Array.from(this.sources.keys()).reverse();
+    const pseudoNameFromPref = Services.prefs.getStringPref("intl.l10n.pseudo", "");
+    for (const locale of requestedLangs) {
+      for (const dataSets of generateResourceSetsForLocaleSync(locale, sourcesOrder, resourceIds)) {
+        const bundle = new FluentBundle(locale, {
+          ...MSG_CONTEXT_OPTIONS,
+          transform: PSEUDO_STRATEGIES[pseudoNameFromPref],
+        });
+        for (const data of dataSets) {
+          if (data === null) {
+            return;
+          }
+          bundle.addResource(data);
+        }
+        yield bundle;
+      }
+    }
+  }
+
+  /**
    * Adds a new resource source to the L10nRegistry.
    *
    * @param {FileSource} source
    */
   registerSource(source) {
     if (this.sources.has(source.name)) {
       throw new Error(`Source with name "${source.name}" already registered.`);
     }
@@ -225,16 +266,19 @@ class L10nRegistryService {
 /**
  * This function generates an iterator over FluentBundles for a single locale
  * for a given list of resourceIds for all possible combinations of sources.
  *
  * This function is called recursively to generate all possible permutations
  * and uses the last, optional parameter, to pass the already resolved
  * sources order.
  *
+ * Notice: Any changes to this method should be copied
+ * to the `generateResourceSetsForLocaleSync` equivalent below.
+ *
  * @param {String} locale
  * @param {Array} sourcesOrder
  * @param {Array} resourceIds
  * @param {Array} [resolvedOrder]
  * @returns {AsyncIterator<FluentBundle>}
  */
 async function* generateResourceSetsForLocale(locale, sourcesOrder, resourceIds, resolvedOrder = []) {
   const resolvedLength = resolvedOrder.length;
@@ -279,16 +323,75 @@ async function* generateResourceSetsForL
     } else if (resolvedLength < resourcesLength) {
       // otherwise recursively load another generator that walks over the
       // partially resolved list of sources.
       yield * generateResourceSetsForLocale(locale, sourcesOrder, resourceIds, order);
     }
   }
 }
 
+/**
+ * This is a synchronous version of the `generateResourceSetsForLocale`
+ * method and should stay completely in sync with it at all
+ * times except of the async/await changes.
+ *
+ * @param {String} locale
+ * @param {Array} sourcesOrder
+ * @param {Array} resourceIds
+ * @param {Array} [resolvedOrder]
+ * @returns {Iterator<FluentBundle>}
+ */
+function* generateResourceSetsForLocaleSync(locale, sourcesOrder, resourceIds, resolvedOrder = []) {
+  const resolvedLength = resolvedOrder.length;
+  const resourcesLength = resourceIds.length;
+
+  // Inside that loop we have a list of resources and the sources for them, like this:
+  //   ['test.ftl', 'menu.ftl', 'foo.ftl']
+  //   ['app', 'platform', 'app']
+  for (const sourceName of sourcesOrder) {
+    const order = resolvedOrder.concat(sourceName);
+
+    // We want to bail out early if we know that any of
+    // the (res)x(source) combinations in the permutation
+    // are unavailable.
+    // The combination may have been `undefined` when we
+    // stepped into this branch, and now is resolved to
+    // `false`.
+    //
+    // If the combination resolved to `false` is the last
+    // in the resolvedOrder, we want to continue in this
+    // loop, but if it's somewhere in the middle, we can
+    // safely bail from the whole branch.
+    for (let [idx, sourceName] of order.entries()) {
+      if (L10nRegistry.sources.get(sourceName).hasFile(locale, resourceIds[idx]) === false) {
+        if (idx === order.length - 1) {
+          continue;
+        } else {
+          return;
+        }
+      }
+    }
+
+    // If the number of resolved sources equals the number of resources,
+    // create the right context and return it if it loads.
+    if (resolvedLength + 1 === resourcesLength) {
+      let dataSet = generateResourceSetSync(locale, order, resourceIds);
+      // Here we check again to see if the newly resolved
+      // resources returned `false` on any position.
+      if (!dataSet.includes(false)) {
+        yield dataSet;
+      }
+    } else if (resolvedLength < resourcesLength) {
+      // otherwise recursively load another generator that walks over the
+      // partially resolved list of sources.
+      yield * generateResourceSetsForLocaleSync(locale, sourcesOrder, resourceIds, order);
+    }
+  }
+}
+
 const MSG_CONTEXT_OPTIONS = {
   // Temporarily disable bidi isolation due to Microsoft not supporting FSI/PDI.
   // See bug 1439018 for details.
   useIsolating: Services.prefs.getBoolPref("intl.l10n.enable-bidi-marks", false),
   functions: {
     /**
      * PLATFORM is a built-in allowing localizers to differentiate message
      * variants depending on the target platform.
@@ -400,42 +503,61 @@ const PSEUDO_STRATEGIES = {
  * Generates a single FluentBundle by loading all resources
  * from the listed sources for a given locale.
  *
  * The function casts all error cases into a Promise that resolves with
  * value `null`.
  * This allows the caller to be an async generator without using
  * try/catch clauses.
  *
+ * Notice: Any changes to this method should be copied
+ * to the `generateResourceSetSync` equivalent below.
+ *
  * @param {String} locale
  * @param {Array} sourcesOrder
  * @param {Array} resourceIds
  * @returns {Promise<FluentBundle>}
  */
 async function generateResourceSet(locale, sourcesOrder, resourceIds) {
   return Promise.all(resourceIds.map((resourceId, i) => {
     return L10nRegistry.sources.get(sourcesOrder[i]).fetchFile(locale, resourceId);
   }));
 }
 
 /**
+ * This is a synchronous version of the `generateResourceSet`
+ * method and should stay completely in sync with it at all
+ * times except of the async/await changes.
+ *
+ * @param {String} locale
+ * @param {Array} sourcesOrder
+ * @param {Array} resourceIds
+ * @returns {FluentBundle}
+ */
+function generateResourceSetSync(locale, sourcesOrder, resourceIds) {
+  return resourceIds.map((resourceId, i) => {
+    return L10nRegistry.sources.get(sourcesOrder[i]).fetchFile(locale, resourceId, {sync: true});
+  });
+}
+
+/**
  * This is a basic Source for L10nRegistry.
  * It registers its own locales and a pre-path, and when asked for a file
  * it attempts to download and cache it.
  *
  * The Source caches the downloaded files so any consecutive loads will
  * come from the cache.
  **/
 class FileSource {
   /**
    * @param {string}         name
    * @param {Array<string>}  locales
    * @param {string}         prePath
    *
-   * @returns {IndexedFileSource}
+   * @returns {FileSource}
    */
   constructor(name, locales, prePath) {
     this.name = name;
     this.locales = locales;
     this.prePath = prePath;
     this.indexed = false;
 
     // The cache object stores information about the resources available
@@ -477,35 +599,48 @@ class FileSource {
       return false;
     }
     if (this.cache[fullPath].then) {
       return undefined;
     }
     return true;
   }
 
-  fetchFile(locale, path) {
+  fetchFile(locale, path, options = {sync: false}) {
     if (!this.locales.includes(locale)) {
       return false;
     }
 
     const fullPath = this.getPath(locale, path);
 
     if (this.cache.hasOwnProperty(fullPath)) {
       if (this.cache[fullPath] === false) {
         return false;
       }
       // `true` means that the file is indexed, but hasn't
       // been fetched yet.
       if (this.cache[fullPath] !== true) {
         return this.cache[fullPath];
       }
     } else if (this.indexed) {
-        return false;
+      return false;
+    }
+    if (options.sync) {
+      let data = L10nRegistry.loadSync(fullPath);
+
+      if (data === false) {
+        this.cache[fullPath] = false;
+      } else {
+        this.cache[fullPath] = FluentResource.fromString(data);
       }
+
+      return this.cache[fullPath];
+    }
+
+    // async
     return this.cache[fullPath] = L10nRegistry.load(fullPath).then(
       data => {
         return this.cache[fullPath] = FluentResource.fromString(data);
       },
       err => {
         this.cache[fullPath] = false;
         return false;
       }
@@ -554,12 +689,34 @@ this.L10nRegistry.load = function(url) {
   return fetch(url).then(response => {
     if (!response.ok) {
       return Promise.reject(response.statusText);
     }
     return response.text();
   });
 };
 
+/**
+ * This is a synchronous version of the `load`
+ * function and should stay completely in sync with it at all
+ * times except of the async/await changes.
+ *
+ * Notice: Any changes to this method should be copied
+ * to the `generateResourceSetSync` equivalent below.
+ *
+ * @param {string} url
+ *
+ * @returns {string}
+ */
+this.L10nRegistry.loadSync = function(uri) {
+  try {
+    let url = Services.io.newURI(uri);
+    let data = Cu.readUTF8URI(url);
+    return data;
+  } catch (e) {
+    return false;
+  }
+};
+
 this.FileSource = FileSource;
 this.IndexedFileSource = IndexedFileSource;
 
 var EXPORTED_SYMBOLS = ["L10nRegistry", "FileSource", "IndexedFileSource"];
new file mode 100644
--- /dev/null
+++ b/intl/l10n/test/test_l10nregistry_sync.js
@@ -0,0 +1,453 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const {
+  L10nRegistry,
+  FileSource,
+  IndexedFileSource,
+} = ChromeUtils.import("resource://gre/modules/L10nRegistry.jsm", {});
+ChromeUtils.import("resource://gre/modules/Timer.jsm");
+
+let fs;
+
+L10nRegistry.loadSync = function(url) {
+  if (!fs.hasOwnProperty(url)) {
+    return false;
+  }
+  return fs[url];
+};
+
+add_task(function test_methods_presence() {
+  equal(typeof L10nRegistry.generateBundles, "function");
+  equal(typeof L10nRegistry.getAvailableLocales, "function");
+  equal(typeof L10nRegistry.registerSource, "function");
+  equal(typeof L10nRegistry.updateSource, "function");
+});
+
+/**
+ * Test that passing empty resourceIds list works.
+ */
+add_task(function test_empty_resourceids() {
+  fs = {};
+
+  const source = new FileSource("test", ["en-US"], "/localization/{locale}");
+  L10nRegistry.registerSource(source);
+
+  const bundles = L10nRegistry.generateBundlesSync(["en-US"], []);
+
+  const done = (bundles.next()).done;
+
+  equal(done, true);
+
+  // cleanup
+  L10nRegistry.sources.clear();
+});
+
+/**
+ * Test that passing empty sources list works.
+ */
+add_task(function test_empty_sources() {
+  fs = {};
+
+  const bundles = L10nRegistry.generateBundlesSync(["en-US"], []);
+
+  const done = (bundles.next()).done;
+
+  equal(done, true);
+
+  // cleanup
+  L10nRegistry.sources.clear();
+});
+
+/**
+ * This test tests generation of a proper context for a single
+ * source scenario
+ */
+add_task(function test_methods_calling() {
+  fs = {
+    "/localization/en-US/browser/menu.ftl": "key = Value",
+  };
+
+  const source = new FileSource("test", ["en-US"], "/localization/{locale}");
+  L10nRegistry.registerSource(source);
+
+  const bundles = L10nRegistry.generateBundlesSync(["en-US"], ["/browser/menu.ftl"]);
+
+  const bundle = (bundles.next()).value;
+
+  equal(bundle.hasMessage("key"), true);
+
+  // cleanup
+  L10nRegistry.sources.clear();
+});
+
+/**
+ * This test verifies that the public methods return expected values
+ * for the single source scenario
+ */
+add_task(function test_has_one_source() {
+  let oneSource = new FileSource("app", ["en-US"], "./app/data/locales/{locale}/");
+  fs = {
+    "./app/data/locales/en-US/test.ftl": "key = value en-US",
+  };
+  L10nRegistry.registerSource(oneSource);
+
+
+  // has one source
+
+  equal(L10nRegistry.sources.size, 1);
+  equal(L10nRegistry.sources.has("app"), true);
+
+
+  // returns a single context
+
+  let bundles = L10nRegistry.generateBundlesSync(["en-US"], ["test.ftl"]);
+  let bundle0 = (bundles.next()).value;
+  equal(bundle0.hasMessage("key"), true);
+
+  equal((bundles.next()).done, true);
+
+
+  // returns no contexts for missing locale
+
+  bundles = L10nRegistry.generateBundlesSync(["pl"], ["test.ftl"]);
+
+  equal((bundles.next()).done, true);
+
+  // cleanup
+  L10nRegistry.sources.clear();
+});
+
+/**
+ * This test verifies that public methods return expected values
+ * for the dual source scenario.
+ */
+add_task(function test_has_two_sources() {
+  let oneSource = new FileSource("platform", ["en-US"], "./platform/data/locales/{locale}/");
+  L10nRegistry.registerSource(oneSource);
+
+  let secondSource = new FileSource("app", ["pl"], "./app/data/locales/{locale}/");
+  L10nRegistry.registerSource(secondSource);
+  fs = {
+    "./platform/data/locales/en-US/test.ftl": "key = platform value",
+    "./app/data/locales/pl/test.ftl": "key = app value",
+  };
+
+
+  // has two sources
+
+  equal(L10nRegistry.sources.size, 2);
+  equal(L10nRegistry.sources.has("app"), true);
+  equal(L10nRegistry.sources.has("platform"), true);
+
+
+  // returns correct contexts for en-US
+
+  let bundles = L10nRegistry.generateBundlesSync(["en-US"], ["test.ftl"]);
+  let bundle0 = (bundles.next()).value;
+
+  equal(bundle0.hasMessage("key"), true);
+  let msg = bundle0.getMessage("key");
+  equal(bundle0.format(msg), "platform value");
+
+  equal((bundles.next()).done, true);
+
+
+  // returns correct contexts for [pl, en-US]
+
+  bundles = L10nRegistry.generateBundlesSync(["pl", "en-US"], ["test.ftl"]);
+  bundle0 = (bundles.next()).value;
+  equal(bundle0.locales[0], "pl");
+  equal(bundle0.hasMessage("key"), true);
+  let msg0 = bundle0.getMessage("key");
+  equal(bundle0.format(msg0), "app value");
+
+  let bundle1 = (bundles.next()).value;
+  equal(bundle1.locales[0], "en-US");
+  equal(bundle1.hasMessage("key"), true);
+  let msg1 = bundle1.getMessage("key");
+  equal(bundle1.format(msg1), "platform value");
+
+  equal((bundles.next()).done, true);
+
+  // cleanup
+  L10nRegistry.sources.clear();
+});
+
+/**
+ * This test verifies that behavior specific to the IndexedFileSource
+ * works correctly.
+ *
+ * In particular it tests that IndexedFileSource correctly returns
+ * missing files as `false` instead of `undefined`.
+ */
+add_task(function test_indexed() {
+  let oneSource = new IndexedFileSource("langpack-pl", ["pl"], "/data/locales/{locale}/", [
+    "/data/locales/pl/test.ftl",
+  ]);
+  L10nRegistry.registerSource(oneSource);
+  fs = {
+    "/data/locales/pl/test.ftl": "key = value",
+  };
+
+  equal(L10nRegistry.sources.size, 1);
+  equal(L10nRegistry.sources.has("langpack-pl"), true);
+
+  equal(oneSource.getPath("pl", "test.ftl"), "/data/locales/pl/test.ftl");
+  equal(oneSource.hasFile("pl", "test.ftl"), true);
+  equal(oneSource.hasFile("pl", "missing.ftl"), false);
+
+  // cleanup
+  L10nRegistry.sources.clear();
+});
+
+/**
+ * This test checks if the correct order of contexts is used for
+ * scenarios where a new file source is added on top of the default one.
+ */
+add_task(function test_override() {
+  let fileSource = new FileSource("app", ["pl"], "/app/data/locales/{locale}/");
+  L10nRegistry.registerSource(fileSource);
+
+  let oneSource = new IndexedFileSource("langpack-pl", ["pl"], "/data/locales/{locale}/", [
+    "/data/locales/pl/test.ftl",
+  ]);
+  L10nRegistry.registerSource(oneSource);
+
+  fs = {
+    "/app/data/locales/pl/test.ftl": "key = value",
+    "/data/locales/pl/test.ftl": "key = addon value",
+  };
+
+  equal(L10nRegistry.sources.size, 2);
+  equal(L10nRegistry.sources.has("langpack-pl"), true);
+
+  let bundles = L10nRegistry.generateBundlesSync(["pl"], ["test.ftl"]);
+  let bundle0 = (bundles.next()).value;
+  equal(bundle0.locales[0], "pl");
+  equal(bundle0.hasMessage("key"), true);
+  let msg0 = bundle0.getMessage("key");
+  equal(bundle0.format(msg0), "addon value");
+
+  let bundle1 = (bundles.next()).value;
+  equal(bundle1.locales[0], "pl");
+  equal(bundle1.hasMessage("key"), true);
+  let msg1 = bundle1.getMessage("key");
+  equal(bundle1.format(msg1), "value");
+
+  equal((bundles.next()).done, true);
+
+  // cleanup
+  L10nRegistry.sources.clear();
+});
+
+/**
+ * This test verifies that new contexts are returned
+ * after source update.
+ */
+add_task(function test_updating() {
+  let oneSource = new IndexedFileSource("langpack-pl", ["pl"], "/data/locales/{locale}/", [
+    "/data/locales/pl/test.ftl",
+  ]);
+  L10nRegistry.registerSource(oneSource);
+  fs = {
+    "/data/locales/pl/test.ftl": "key = value",
+  };
+
+  let bundles = L10nRegistry.generateBundlesSync(["pl"], ["test.ftl"]);
+  let bundle0 = (bundles.next()).value;
+  equal(bundle0.locales[0], "pl");
+  equal(bundle0.hasMessage("key"), true);
+  let msg0 = bundle0.getMessage("key");
+  equal(bundle0.format(msg0), "value");
+
+
+  const newSource = new IndexedFileSource("langpack-pl", ["pl"], "/data/locales/{locale}/", [
+    "/data/locales/pl/test.ftl",
+  ]);
+  fs["/data/locales/pl/test.ftl"] = "key = new value";
+  L10nRegistry.updateSource(newSource);
+
+  equal(L10nRegistry.sources.size, 1);
+  bundles = L10nRegistry.generateBundlesSync(["pl"], ["test.ftl"]);
+  bundle0 = (bundles.next()).value;
+  msg0 = bundle0.getMessage("key");
+  equal(bundle0.format(msg0), "new value");
+
+  // cleanup
+  L10nRegistry.sources.clear();
+});
+
+/**
+ * This test verifies that generated contexts return correct values
+ * after sources are being removed.
+ */
+add_task(function test_removing() {
+  let fileSource = new FileSource("app", ["pl"], "/app/data/locales/{locale}/");
+  L10nRegistry.registerSource(fileSource);
+
+  let oneSource = new IndexedFileSource("langpack-pl", ["pl"], "/data/locales/{locale}/", [
+    "/data/locales/pl/test.ftl",
+  ]);
+  L10nRegistry.registerSource(oneSource);
+
+  fs = {
+    "/app/data/locales/pl/test.ftl": "key = value",
+    "/data/locales/pl/test.ftl": "key = addon value",
+  };
+
+  equal(L10nRegistry.sources.size, 2);
+  equal(L10nRegistry.sources.has("langpack-pl"), true);
+
+  let bundles = L10nRegistry.generateBundlesSync(["pl"], ["test.ftl"]);
+  let bundle0 = (bundles.next()).value;
+  equal(bundle0.locales[0], "pl");
+  equal(bundle0.hasMessage("key"), true);
+  let msg0 = bundle0.getMessage("key");
+  equal(bundle0.format(msg0), "addon value");
+
+  let bundle1 = (bundles.next()).value;
+  equal(bundle1.locales[0], "pl");
+  equal(bundle1.hasMessage("key"), true);
+  let msg1 = bundle1.getMessage("key");
+  equal(bundle1.format(msg1), "value");
+
+  equal((bundles.next()).done, true);
+
+  // Remove langpack
+
+  L10nRegistry.removeSource("langpack-pl");
+
+  equal(L10nRegistry.sources.size, 1);
+  equal(L10nRegistry.sources.has("langpack-pl"), false);
+
+  bundles = L10nRegistry.generateBundlesSync(["pl"], ["test.ftl"]);
+  bundle0 = (bundles.next()).value;
+  equal(bundle0.locales[0], "pl");
+  equal(bundle0.hasMessage("key"), true);
+  msg0 = bundle0.getMessage("key");
+  equal(bundle0.format(msg0), "value");
+
+  equal((bundles.next()).done, true);
+
+  // Remove app source
+
+  L10nRegistry.removeSource("app");
+
+  equal(L10nRegistry.sources.size, 0);
+
+  bundles = L10nRegistry.generateBundlesSync(["pl"], ["test.ftl"]);
+  equal((bundles.next()).done, true);
+
+  // cleanup
+  L10nRegistry.sources.clear();
+});
+
+/**
+ * This test verifies that the logic works correctly when there's a missing
+ * file in the FileSource scenario.
+ */
+add_task(function test_missing_file() {
+  let oneSource = new FileSource("app", ["en-US"], "./app/data/locales/{locale}/");
+  L10nRegistry.registerSource(oneSource);
+  let twoSource = new FileSource("platform", ["en-US"], "./platform/data/locales/{locale}/");
+  L10nRegistry.registerSource(twoSource);
+
+  fs = {
+    "./app/data/locales/en-US/test.ftl": "key = value en-US",
+    "./platform/data/locales/en-US/test.ftl": "key = value en-US",
+    "./platform/data/locales/en-US/test2.ftl": "key2 = value2 en-US",
+  };
+
+
+  // has two sources
+
+  equal(L10nRegistry.sources.size, 2);
+  equal(L10nRegistry.sources.has("app"), true);
+  equal(L10nRegistry.sources.has("platform"), true);
+
+
+  // returns a single context
+
+  let bundles = L10nRegistry.generateBundlesSync(["en-US"], ["test.ftl", "test2.ftl"]);
+
+  // First permutation:
+  //   [platform, platform] - both present
+  let bundle1 = (bundles.next());
+  equal(bundle1.value.hasMessage("key"), true);
+
+  // Second permutation skipped:
+  //   [platform, app] - second missing
+  // Third permutation:
+  //   [app, platform] - both present
+  let bundle2 = (bundles.next());
+  equal(bundle2.value.hasMessage("key"), true);
+
+  // Fourth permutation skipped:
+  //   [app, app] - second missing
+  equal((bundles.next()).done, true);
+
+  // cleanup
+  L10nRegistry.sources.clear();
+});
+
+/**
+ * This test verifies that each file is that all files requested
+ * by a single context are fetched at the same time, even
+ * if one I/O is slow.
+ */
+add_task(function test_parallel_io() {
+  /* eslint-disable mozilla/no-arbitrary-setTimeout */
+  let originalLoad = L10nRegistry.load;
+  let fetchIndex = new Map();
+
+  L10nRegistry.load = function(url) {
+    if (!fetchIndex.has(url)) {
+      fetchIndex.set(url, 0);
+    }
+    fetchIndex.set(url, fetchIndex.get(url) + 1);
+
+    if (url === "/en-US/slow-file.ftl") {
+      return new Promise((resolve, reject) => {
+        setTimeout(() => {
+          // Despite slow-file being the first on the list,
+          // by the time the it finishes loading, the other
+          // two files are already fetched.
+          equal(fetchIndex.get("/en-US/test.ftl"), 1);
+          equal(fetchIndex.get("/en-US/test2.ftl"), 1);
+
+          resolve("");
+        }, 10);
+      });
+    }
+    return Promise.resolve("");
+  };
+  let oneSource = new FileSource("app", ["en-US"], "/{locale}/");
+  L10nRegistry.registerSource(oneSource);
+
+  fs = {
+    "/en-US/test.ftl": "key = value en-US",
+    "/en-US/test2.ftl": "key2 = value2 en-US",
+    "/en-US/slow-file.ftl": "key-slow = value slow en-US",
+  };
+
+  // returns a single context
+
+  let bundles = L10nRegistry.generateBundlesSync(["en-US"], ["slow-file.ftl", "test.ftl", "test2.ftl"]);
+
+  equal(fetchIndex.size, 0);
+
+  let bundle0 = bundles.next();
+
+  equal(bundle0.done, false);
+
+  equal((bundles.next()).done, true);
+
+  // When requested again, the cache should make the load operation not
+  // increase the fetchedIndex count
+  L10nRegistry.generateBundlesSync(["en-US"], ["test.ftl", "test2.ftl", "slow-file.ftl"]);
+
+  // cleanup
+  L10nRegistry.sources.clear();
+  L10nRegistry.load = originalLoad;
+});
--- a/intl/l10n/test/xpcshell.ini
+++ b/intl/l10n/test/xpcshell.ini
@@ -1,9 +1,10 @@
 [DEFAULT]
 head =
 
 [test_domlocalization.js]
 [test_l10nregistry.js]
+[test_l10nregistry_sync.js]
 [test_localization.js]
 [test_messagecontext.js]
 [test_mozdomlocalization.js]
 [test_pseudo.js]