Bug 820834 - Abstract about:home storage and make it async-ready.
authorMarco Bonardo <mbonardo@mozilla.com>
Wed, 27 Feb 2013 18:57:48 +0100
changeset 123831 d958753bd1a23b1a34bf32067b98bc1957b53b94
parent 123830 b6594d068e74ef999c37b897145e918951ae1391
child 123832 1fea3c18dfd7b2aa8b0dd33347b511ad2714adea
push id1401
push userpastithas@mozilla.com
push dateThu, 07 Mar 2013 07:26:45 +0000
treeherderfx-team@ee4879719f78 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
bugs820834
milestone22.0a1
Bug 820834 - Abstract about:home storage and make it async-ready. r=gavin
browser/base/content/abouthome/aboutHome.js
browser/base/content/test/browser_aboutHome.js
browser/modules/AboutHomeUtils.jsm
--- a/browser/base/content/abouthome/aboutHome.js
+++ b/browser/base/content/abouthome/aboutHome.js
@@ -81,30 +81,93 @@ const DEFAULT_SNIPPETS_URLS = [
 
 const SNIPPETS_UPDATE_INTERVAL_MS = 86400000; // 1 Day.
 
 let gObserver = new MutationObserver(function (mutations) {
   for (let mutation of mutations) {
     if (mutation.attributeName == "searchEngineURL") {
       gObserver.disconnect();
       setupSearchEngine();
-      loadSnippets();
+      ensureSnippetsMapThen(loadSnippets);
       return;
     }
   }
 });
 
 window.addEventListener("load", function () {
   // Delay search engine setup, cause browser.js::BrowserOnAboutPageLoad runs
   // later and may use asynchronous getters.
   window.gObserver.observe(document.documentElement, { attributes: true });
   fitToWidth();
   window.addEventListener("resize", fitToWidth);
 });
 
+// This object has the same interface as Map and is used to store and retrieve
+// the snippets data.  It is lazily initialized by ensureSnippetsMapThen(), so
+// be sure its callback returned before trying to use it.
+let gSnippetsMap;
+let gSnippetsMapCallbacks = [];
+
+/**
+ * Ensure the snippets map is properly initialized.
+ *
+ * @param aCallback
+ *        Invoked once the map has been initialized, gets the map as argument.
+ * @note Snippets should never directly manage the underlying storage, since
+ *       it may change inadvertently.
+ */
+function ensureSnippetsMapThen(aCallback)
+{
+  if (gSnippetsMap) {
+    aCallback(gSnippetsMap);
+    return;
+  }
+
+  // Handle multiple requests during the async initialization.
+  gSnippetsMapCallbacks.push(aCallback);
+  if (gSnippetsMapCallbacks.length > 1) {
+    // We are already updating, the callbacks will be invoked when done.
+    return;
+  }
+
+  // TODO (bug 789348): use a real asynchronous storage here.  This setTimeout
+  // is done just to catch bugs with the asynchronous behavior.
+  setTimeout(function() {
+    // Populate the cache from the persistent storage.
+    let cache = new Map();
+    for (let key of [ "snippets-last-update",
+                      "snippets" ]) {
+      cache.set(key, localStorage[key]);
+    }
+
+    gSnippetsMap = Object.freeze({
+      get: function (aKey) cache.get(aKey),
+      set: function (aKey, aValue) {
+        localStorage[aKey] = aValue;
+        return cache.set(aKey, aValue);
+      },
+      has: function(aKey) cache.has(aKey),
+      delete: function(aKey) {
+        delete localStorage[aKey];
+        return cache.delete(aKey);
+      },
+      clear: function() {
+        localStorage.clear();
+        return cache.clear();
+      },
+      get size() cache.size
+    });
+
+    for (let callback of gSnippetsMapCallbacks) {
+      callback(gSnippetsMap);
+    }
+    gSnippetsMapCallbacks.length = 0;
+  }, 0);
+}
+
 function onSearchSubmit(aEvent)
 {
   let searchTerms = document.getElementById("searchText").value;
   let searchURL = document.documentElement.getAttribute("searchEngineURL");
 
   if (searchURL && searchTerms.length > 0) {
     const SEARCH_TOKENS = {
       "_searchTerms_": encodeURIComponent(searchTerms)
@@ -152,54 +215,79 @@ function setupSearchEngine()
   let searchText = document.getElementById("searchText");
   searchText.addEventListener("blur", function searchText_onBlur() {
     searchText.removeEventListener("blur", searchText_onBlur);
     searchText.removeAttribute("autofocus");
   });
 
 }
 
+/**
+ * Update the local snippets from the remote storage, then show them through
+ * showSnippets.
+ */
 function loadSnippets()
 {
+  if (!gSnippetsMap)
+    throw new Error("Snippets map has not properly been initialized");
+
   // Check last snippets update.
-  let lastUpdate = localStorage["snippets-last-update"];
+  let lastUpdate = gSnippetsMap.get("snippets-last-update");
   let updateURL = document.documentElement.getAttribute("snippetsURL");
-  if (updateURL && (!lastUpdate ||
-                    Date.now() - lastUpdate > SNIPPETS_UPDATE_INTERVAL_MS)) {
+  let shouldUpdate = !lastUpdate ||
+                     Date.now() - lastUpdate > SNIPPETS_UPDATE_INTERVAL_MS;
+  if (updateURL && shouldUpdate) {
     // Try to update from network.
     let xhr = new XMLHttpRequest();
     try {
       xhr.open("GET", updateURL, true);
     } catch (ex) {
       showSnippets();
       return;
     }
     // Even if fetching should fail we don't want to spam the server, thus
     // set the last update time regardless its results.  Will retry tomorrow.
-    localStorage["snippets-last-update"] = Date.now();
+    gSnippetsMap.set("snippets-last-update", Date.now());
     xhr.onerror = function (event) {
       showSnippets();
     };
     xhr.onload = function (event)
     {
       if (xhr.status == 200) {
-        localStorage["snippets"] = xhr.responseText;
+        gSnippetsMap.set("snippets", xhr.responseText);
       }
       showSnippets();
     };
     xhr.send(null);
   } else {
     showSnippets();
   }
 }
 
+/**
+ * Shows locally cached remote snippets, or default ones when not available.
+ *
+ * @note: snippets should never invoke showSnippets(), or they may cause
+ *        a "too much recursion" exception.
+ */
+let _snippetsShown = false;
 function showSnippets()
 {
+  if (!gSnippetsMap)
+    throw new Error("Snippets map has not properly been initialized");
+  if (_snippetsShown) {
+    // There's something wrong with the remote snippets, just in case fall back
+    // to the default snippets.
+    showDefaultSnippets();
+    throw new Error("showSnippets should never be invoked multiple times");
+  }
+  _snippetsShown = true;
+
   let snippetsElt = document.getElementById("snippets");
-  let snippets = localStorage["snippets"];
+  let snippets = gSnippetsMap.get("snippets");
   // If there are remotely fetched snippets, try to to show them.
   if (snippets) {
     // Injecting snippets can throw if they're invalid XML.
     try {
       snippetsElt.innerHTML = snippets;
       // Scripts injected by innerHTML are inactive, so we have to relocate them
       // through DOM manipulation to activate their contents.
       Array.forEach(snippetsElt.getElementsByTagName("script"), function(elt) {
@@ -209,17 +297,29 @@ function showSnippets()
         elt.parentNode.replaceChild(relocatedScript, elt);
       });
       return;
     } catch (ex) {
       // Bad content, continue to show default snippets.
     }
   }
 
-  // Show default snippets otherwise.
+  showDefaultSnippets();
+}
+
+/**
+ * Clear snippets element contents and show default snippets.
+ */
+function showDefaultSnippets()
+{
+  // Clear eventual contents...
+  let snippetsElt = document.getElementById("snippets");
+  snippetsElt.innerHTML = "";
+
+  // ...then show default snippets.
   let defaultSnippetsElt = document.getElementById("defaultSnippets");
   let entries = defaultSnippetsElt.querySelectorAll("span");
   // Choose a random snippet.  Assume there is always at least one.
   let randIndex = Math.floor(Math.random() * entries.length);
   let entry = entries[randIndex];
   // Inject url in the eventual link.
   if (DEFAULT_SNIPPETS_URLS[randIndex]) {
     let links = entry.getElementsByTagName("a");
--- a/browser/base/content/test/browser_aboutHome.js
+++ b/browser/base/content/test/browser_aboutHome.js
@@ -1,12 +1,17 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/
  */
 
+XPCOMUtils.defineLazyModuleGetter(this, "Promise",
+  "resource://gre/modules/commonjs/sdk/core/promise.js");
+XPCOMUtils.defineLazyModuleGetter(this, "Task",
+  "resource://gre/modules/Task.jsm");
+
 registerCleanupFunction(function() {
   // Ensure we don't pollute prefs for next tests.
   try {
     Services.prefs.clearUserPref("network.cookies.cookieBehavior");
   } catch (ex) {}
   try {
     Services.prefs.clearUserPref("network.cookie.lifetimePolicy");
   } catch (ex) {}
@@ -17,113 +22,107 @@ let gTests = [
 {
   desc: "Check that clearing cookies does not clear storage",
   setup: function ()
   {
     Cc["@mozilla.org/dom/storagemanager;1"]
       .getService(Ci.nsIObserver)
       .observe(null, "cookie-changed", "cleared");
   },
-  run: function ()
+  run: function (aSnippetsMap)
   {
-    let storage = getStorage();
-    isnot(storage.getItem("snippets-last-update"), null);
-    executeSoon(runNextTest);
+    isnot(aSnippetsMap.get("snippets-last-update"), null);
   }
 },
 
 {
   desc: "Check default snippets are shown",
-  setup: function ()
-  {
-  },
+  setup: function () { },
   run: function ()
   {
     let doc = gBrowser.selectedTab.linkedBrowser.contentDocument;
     let snippetsElt = doc.getElementById("snippets");
     ok(snippetsElt, "Found snippets element")
     is(snippetsElt.getElementsByTagName("span").length, 1,
        "A default snippet is visible.");
-    executeSoon(runNextTest);
   }
 },
 
 {
   desc: "Check default snippets are shown if snippets are invalid xml",
-  setup: function ()
+  setup: function (aSnippetsMap)
   {
-    let storage = getStorage();
     // This must be some incorrect xhtml code.
-    storage.setItem("snippets", "<p><b></p></b>");
+    aSnippetsMap.set("snippets", "<p><b></p></b>");
   },
-  run: function ()
+  run: function (aSnippetsMap)
   {
     let doc = gBrowser.selectedTab.linkedBrowser.contentDocument;
 
     let snippetsElt = doc.getElementById("snippets");
     ok(snippetsElt, "Found snippets element");
     is(snippetsElt.getElementsByTagName("span").length, 1,
        "A default snippet is visible.");
-    let storage = getStorage();
-    storage.removeItem("snippets");
-    executeSoon(runNextTest);
+
+    aSnippetsMap.delete("snippets");
   }
 },
+
 {
   desc: "Check that search engine logo has alt text",
-  setup: function ()
-  {
-  },
+  setup: function () { },
   run: function ()
   {
     let doc = gBrowser.selectedTab.linkedBrowser.contentDocument;
 
     let searchEngineLogoElt = doc.getElementById("searchEngineLogo");
     ok(searchEngineLogoElt, "Found search engine logo");
 
     let altText = searchEngineLogoElt.alt;
     ok(typeof altText == "string" && altText.length > 0,
        "Search engine logo's alt text is a nonempty string");
 
     isnot(altText, "undefined",
           "Search engine logo's alt text shouldn't be the string 'undefined'");
-
-    executeSoon(runNextTest);
   }
 },
+
 {
   desc: "Check that performing a search fires a search event.",
   setup: function () { },
   run: function () {
+    let deferred = Promise.defer();
     let doc = gBrowser.contentDocument;
 
     doc.addEventListener("AboutHomeSearchEvent", function onSearch(e) {
       is(e.detail, doc.documentElement.getAttribute("searchEngineName"), "Detail is search engine name");
 
       gBrowser.stop();
-      executeSoon(runNextTest);
+      deferred.resolve();
     }, true, true);
 
     doc.getElementById("searchText").value = "it works";
     doc.getElementById("searchSubmit").click();
-  },
+    return deferred.promise;
+  }
 },
+
 {
   desc: "Check that performing a search records to Firefox Health Report.",
   setup: function () { },
   run: function () {
     try {
       let cm = Cc["@mozilla.org/categorymanager;1"].getService(Ci.nsICategoryManager);
       cm.getCategoryEntry("healthreport-js-provider", "SearchesProvider");
     } catch (ex) {
       // Health Report disabled, or no SearchesProvider.
-      runNextTest();
       return;
     }
 
+    let deferred = Promise.defer();
     let doc = gBrowser.contentDocument;
 
     // We rely on the listener in browser.js being installed and fired before
     // this one. If this ever changes, we should add an executeSoon() or similar.
     doc.addEventListener("AboutHomeSearchEvent", function onSearch(e) {
       executeSoon(gBrowser.stop.bind(gBrowser));
       let reporter = Components.classes["@mozilla.org/datareporting/service;1"]
                                        .getService()
@@ -144,75 +143,131 @@ let gTests = [
 
           let day = data.days.getDay(now);
           let field = engineName + ".abouthome";
           ok(day.has(field), "Have data for about home on this engine.");
 
           // Note the search from the previous test.
           is(day.get(field), 2, "Have searches recorded.");
 
-          executeSoon(runNextTest);
+          deferred.resolve();
         });
 
       });
     }, true, true);
 
     doc.getElementById("searchText").value = "a search";
     doc.getElementById("searchSubmit").click();
-  },
+    return deferred.promise;
+  }
 },
+
 ];
 
 function test()
 {
   waitForExplicitFinish();
 
-  // Ensure that by default we don't try to check for remote snippets since that
-  // could be tricky due to network bustages or slowness.
-  let storage = getStorage();
-  storage.setItem("snippets-last-update", Date.now());
-  storage.removeItem("snippets");
+  Task.spawn(function () {
+    for (let test of gTests) {
+      info(test.desc);
+
+      let tab = yield promiseNewTabLoadEvent("about:home", "DOMContentLoaded");
+
+      // Must wait for both the snippets map and the browser attributes, since
+      // can't guess the order they will happen.
+      // So, start listening now, but verify the promise is fulfilled only
+      // after the snippets map setup.
+      let promise = promiseBrowserAttributes(tab);
+      // Prepare the snippets map with default values, then run the test setup.
+      let snippetsMap = yield promiseSetupSnippetsMap(tab, test.setup);
+      // Ensure browser has set attributes already, or wait for them.
+      yield promise;
+
+      yield test.run(snippetsMap);
+
+      gBrowser.removeCurrentTab();
+    }
 
-  executeSoon(runNextTest);
+    finish();
+  });
+}
+
+/**
+ * Creates a new tab and waits for a load event.
+ *
+ * @param aUrl
+ *        The url to load in a new tab.
+ * @param aEvent
+ *        The load event type to wait for.  Defaults to "load".
+ * @return {Promise} resolved when the event is handled.  Gets the new tab.
+ */
+function promiseNewTabLoadEvent(aUrl, aEventType="load")
+{
+  let deferred = Promise.defer();
+  let tab = gBrowser.selectedTab = gBrowser.addTab(aUrl);
+  tab.linkedBrowser.addEventListener(aEventType, function load(event) {
+    tab.linkedBrowser.removeEventListener(aEventType, load, true);
+    deferred.resolve(tab);
+  }, true);
+  return deferred.promise;
 }
 
-function runNextTest()
+/**
+ * Cleans up snippets and ensures that by default we don't try to check for
+ * remote snippets since that may cause network bustage or slowness.
+ *
+ * @param aTab
+ *        The tab containing about:home.
+ * @param aSetupFn
+ *        The setup function to be run.
+ * @return {Promise} resolved when the snippets are ready.  Gets the snippets map.
+ */
+function promiseSetupSnippetsMap(aTab, aSetupFn)
 {
-  while (gBrowser.tabs.length > 1) {
-    gBrowser.removeCurrentTab();
-  }
-
-  if (gTests.length) {
-    let test = gTests.shift();
-    info(test.desc);
-    test.setup();
-    let tab = gBrowser.selectedTab = gBrowser.addTab("about:home");
-    tab.linkedBrowser.addEventListener("load", function load(event) {
-      tab.linkedBrowser.removeEventListener("load", load, true);
-
-      let observer = new MutationObserver(function (mutations) {
-        for (let mutation of mutations) {
-          if (mutation.attributeName == "searchEngineURL") {
-            observer.disconnect();
-            executeSoon(test.run);
-            return;
-          }
-        }
-      });
-      let docElt = tab.linkedBrowser.contentDocument.documentElement;
-      observer.observe(docElt, { attributes: true });
-    }, true);
-  }
-  else {
-    finish();
-  }
+  let deferred = Promise.defer();
+  let cw = aTab.linkedBrowser.contentWindow.wrappedJSObject;
+  cw.ensureSnippetsMapThen(function (aSnippetsMap) {
+    // Don't try to update.
+    aSnippetsMap.set("snippets-last-update", Date.now());
+    // Clear snippets.
+    aSnippetsMap.delete("snippets");
+    aSetupFn(aSnippetsMap);
+    // Must be sure to continue after the page snippets map setup.
+    executeSoon(function() deferred.resolve(aSnippetsMap));
+  });
+  return deferred.promise;
 }
 
-function getStorage()
+/**
+ * Waits for the attributes being set by browser.js and overwrites snippetsURL
+ * to ensure we won't try to hit the network and we can force xhr to throw.
+ *
+ * @param aTab
+ *        The tab containing about:home.
+ * @return {Promise} resolved when the attributes are ready.
+ */
+function promiseBrowserAttributes(aTab)
 {
-  let aboutHomeURI = Services.io.newURI("moz-safe-about:home", null, null);
-  let principal = Components.classes["@mozilla.org/scriptsecuritymanager;1"].
-                  getService(Components.interfaces.nsIScriptSecurityManager).
-                  getNoAppCodebasePrincipal(Services.io.newURI("about:home", null, null));
-  let dsm = Components.classes["@mozilla.org/dom/storagemanager;1"].
-            getService(Components.interfaces.nsIDOMStorageManager);
-  return dsm.getLocalStorageForPrincipal(principal, "");
-};
+  let deferred = Promise.defer();
+
+  let docElt = aTab.linkedBrowser.contentDocument.documentElement;
+  //docElt.setAttribute("snippetsURL", "nonexistent://test");
+  let observer = new MutationObserver(function (mutations) {
+    for (let mutation of mutations) {
+      if (mutation.attributeName == "snippetsURL" &&
+          docElt.getAttribute("snippetsURL") != "nonexistent://test") {
+        docElt.setAttribute("snippetsURL", "nonexistent://test");
+      }
+
+      // Now we just have to wait for the last attribute.
+      if (mutation.attributeName == "searchEngineURL") {
+        observer.disconnect();
+        // Must be sure to continue after the page mutation observer.
+        executeSoon(function() deferred.resolve());
+        break;
+      }
+    }
+  });
+  observer.observe(docElt, { attributes: true });
+
+  return deferred.promise;
+}
--- a/browser/modules/AboutHomeUtils.jsm
+++ b/browser/modules/AboutHomeUtils.jsm
@@ -8,17 +8,17 @@ this.EXPORTED_SYMBOLS = [ "AboutHomeUtil
 
 Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
 Components.utils.import("resource://gre/modules/Services.jsm");
 
 // Url to fetch snippets, in the urlFormatter service format.
 const SNIPPETS_URL_PREF = "browser.aboutHomeSnippets.updateUrl";
 
 // Should be bumped up if the snippets content format changes.
-const STARTPAGE_VERSION = 3;
+const STARTPAGE_VERSION = 4;
 
 this.AboutHomeUtils = new Object();
 
 /**
  * Returns an object containing the name and searchURL of the original default
  * search engine.
  */
 XPCOMUtils.defineLazyGetter(AboutHomeUtils, "defaultSearchEngine", function() {