Bug 1171256 - Add an API similar to Chrome's webNavigation (r=Mossop)
authorBill McCloskey <billm@mozilla.com>
Wed, 03 Jun 2015 14:53:12 -0700
changeset 249862 7eca38a74be8389935720bd096d6c23a01268a21
parent 249861 f8f4a6c0654967a5514f78e47480356bd642ec8b
child 249863 fe6e29cd1894d960b223a4291ffb992372c3b467
push id28940
push usercbook@mozilla.com
push dateMon, 22 Jun 2015 12:03:34 +0000
treeherdermozilla-central@be81b8d6fae9 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersMossop
bugs1171256
milestone41.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 1171256 - Add an API similar to Chrome's webNavigation (r=Mossop)
toolkit/modules/addons/WebNavigation.jsm
toolkit/modules/addons/WebNavigationContent.js
toolkit/modules/moz.build
toolkit/modules/tests/browser/browser.ini
toolkit/modules/tests/browser/browser_WebNavigation.js
toolkit/modules/tests/browser/file_WebNavigation_page1.html
toolkit/modules/tests/browser/file_WebNavigation_page2.html
toolkit/modules/tests/browser/file_WebNavigation_page3.html
new file mode 100644
--- /dev/null
+++ b/toolkit/modules/addons/WebNavigation.jsm
@@ -0,0 +1,157 @@
+/* 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/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["WebNavigation"];
+
+const Ci = Components.interfaces;
+const Cc = Components.classes;
+const Cu = Components.utils;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+
+// TODO:
+// Transition types and qualifiers
+// onReferenceFragmentUpdated also triggers for pushState
+// getFrames, getAllFrames
+// onCreatedNavigationTarget, onHistoryStateUpdated
+
+let Manager = {
+  listeners: new Map(),
+
+  init() {
+    Services.mm.addMessageListener("Extension:DOMContentLoaded", this);
+    Services.mm.addMessageListener("Extension:StateChange", this);
+    Services.mm.addMessageListener("Extension:LocationChange", this);
+    Services.mm.loadFrameScript("resource://gre/modules/WebNavigationContent.js", true);
+  },
+
+  uninit() {
+    Services.mm.removeMessageListener("Extension:StateChange", this);
+    Services.mm.removeMessageListener("Extension:LocationChange", this);
+    Services.mm.removeMessageListener("Extension:DOMContentLoaded", this);
+    Services.mm.removeDelayedFrameScript("resource://gre/modules/WebNavigationContent.js");
+    Services.mm.broadcastAsyncMessage("Extension:DisableWebNavigation");
+  },
+
+  addListener(type, listener) {
+    if (this.listeners.size == 0) {
+      this.init();
+    }
+
+    if (!this.listeners.has(type)) {
+      this.listeners.set(type, new Set());
+    }
+    let listeners = this.listeners.get(type);
+    listeners.add(listener);
+  },
+
+  removeListener(type, listener) {
+    let listeners = this.listeners.get(type);
+    if (!listeners) {
+      return;
+    }
+    listeners.delete(listener);
+    if (listeners.size == 0) {
+      this.listeners.delete(type);
+    }
+
+    if (this.listeners.size == 0) {
+      this.uninit();
+    }
+  },
+
+  receiveMessage({name, data, target}) {
+    switch (name) {
+    case "Extension:StateChange":
+      this.onStateChange(target, data);
+      break;
+
+    case "Extension:LocationChange":
+      this.onLocationChange(target, data);
+      break;
+
+    case "Extension:DOMContentLoaded":
+      this.onLoad(target, data);
+      break;
+    }
+  },
+
+  onStateChange(browser, data) {
+    let stateFlags = data.stateFlags;
+    if (stateFlags & Ci.nsIWebProgressListener.STATE_IS_WINDOW) {
+      let url = data.requestURL;
+      if (stateFlags & Ci.nsIWebProgressListener.STATE_START) {
+        this.fire("onBeforeNavigate", browser, data, {url});
+      } else if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP) {
+        if (Components.isSuccessCode(data.status)) {
+          this.fire("onCompleted", browser, data, {url});
+        } else {
+          let error = `Error code ${data.status}`;
+          this.fire("onErrorOccurred", browser, data, {error, url});
+        }
+      }
+    }
+  },
+
+  onLocationChange(browser, data) {
+    let url = data.location;
+    if (data.flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) {
+      this.fire("onReferenceFragmentUpdated", browser, data, {url});
+    } else {
+      this.fire("onCommitted", browser, data, {url});
+    }
+  },
+
+  onLoad(browser, data) {
+    this.fire("onDOMContentLoaded", browser, data, {url: data.url});
+  },
+
+  fire(type, browser, data, extra) {
+    let listeners = this.listeners.get(type);
+    if (!listeners) {
+      return;
+    }
+
+    let details = {
+      browser,
+      windowId: data.windowId,
+    };
+
+    if (data.parentWindowId) {
+      details.parentWindowId = data.parentWindowId;
+    }
+
+    for (let prop in extra) {
+      details[prop] = extra[prop];
+    }
+
+    for (let listener of listeners) {
+      listener(details);
+    }
+  },
+};
+
+const EVENTS = [
+  "onBeforeNavigate",
+  "onCommitted",
+  "onDOMContentLoaded",
+  "onCompleted",
+  "onErrorOccurred",
+  "onReferenceFragmentUpdated",
+
+  //"onCreatedNavigationTarget",
+  //"onHistoryStateUpdated",
+];
+
+let WebNavigation = {};
+
+for (let event of EVENTS) {
+  WebNavigation[event] = {
+    addListener: Manager.addListener.bind(Manager, event),
+    removeListener: Manager.removeListener.bind(Manager, event),
+  }
+}
new file mode 100644
--- /dev/null
+++ b/toolkit/modules/addons/WebNavigationContent.js
@@ -0,0 +1,105 @@
+const Ci = Components.interfaces;
+
+function getWindowId(window)
+{
+  return window.QueryInterface(Ci.nsIInterfaceRequestor)
+               .getInterface(Ci.nsIDOMWindowUtils)
+               .outerWindowID;
+}
+
+function getParentWindowId(window)
+{
+  return getWindowId(window.parent);
+}
+
+function loadListener(event)
+{
+  let document = event.target;
+  let window = document.defaultView;
+  let url = document.documentURI;
+  let windowId = getWindowId(window);
+  let parentWindowId = getParentWindowId(window);
+  sendAsyncMessage("Extension:DOMContentLoaded", {windowId, parentWindowId, url});
+}
+
+addEventListener("DOMContentLoaded", loadListener);
+addMessageListener("Extension:DisableWebNavigation", () => {
+  removeEventListener("DOMContentLoaded", loadListener);
+});
+
+let WebProgressListener = {
+  init: function() {
+    let webProgress = docShell.QueryInterface(Ci.nsIInterfaceRequestor)
+                              .getInterface(Ci.nsIWebProgress);
+    webProgress.addProgressListener(this, Ci.nsIWebProgress.NOTIFY_STATE_WINDOW |
+                                          Ci.nsIWebProgress.NOTIFY_LOCATION);
+  },
+
+  uninit() {
+    if (!docShell) {
+      return;
+    }
+    let webProgress = docShell.QueryInterface(Ci.nsIInterfaceRequestor)
+                              .getInterface(Ci.nsIWebProgress);
+    webProgress.removeProgressListener(this);
+  },
+
+  onStateChange: function onStateChange(webProgress, request, stateFlags, status) {
+    let data = {
+      requestURL: request.QueryInterface(Ci.nsIChannel).URI.spec,
+      windowId: webProgress.DOMWindowID,
+      parentWindowId: getParentWindowId(webProgress.DOMWindow),
+      status,
+      stateFlags,
+    };
+    sendAsyncMessage("Extension:StateChange", data);
+
+    if (webProgress.DOMWindow.top != webProgress.DOMWindow) {
+      let webNav = webProgress.QueryInterface(Ci.nsIWebNavigation);
+      if (!webNav.canGoBack) {
+        // For some reason we don't fire onLocationChange for the
+        // initial navigation of a sub-frame. So we need to simulate
+        // it here.
+        let data = {
+          location: request.QueryInterface(Ci.nsIChannel).URI.spec,
+          windowId: webProgress.DOMWindowID,
+          parentWindowId: getParentWindowId(webProgress.DOMWindow),
+          flags: 0,
+        };
+        sendAsyncMessage("Extension:LocationChange", data);
+      }
+    }
+  },
+
+  onLocationChange: function onLocationChange(webProgress, request, locationURI, flags) {
+    let data = {
+      location: locationURI ? locationURI.spec : "",
+      windowId: webProgress.DOMWindowID,
+      parentWindowId: getParentWindowId(webProgress.DOMWindow),
+      flags,
+    };
+    sendAsyncMessage("Extension:LocationChange", data);
+  },
+
+  QueryInterface: function QueryInterface(aIID) {
+    if (aIID.equals(Ci.nsIWebProgressListener) ||
+        aIID.equals(Ci.nsISupportsWeakReference) ||
+        aIID.equals(Ci.nsISupports)) {
+        return this;
+    }
+
+    throw Components.results.NS_ERROR_NO_INTERFACE;
+  }
+};
+
+let disabled = false;
+WebProgressListener.init();
+addEventListener("unload", () => {
+  if (!disabled) {
+    WebProgressListener.uninit();
+  }
+});
+addMessageListener("Extension:DisableWebNavigation", () => {
+  disabled = true;
+  WebProgressListener.uninit();
+});
--- a/toolkit/modules/moz.build
+++ b/toolkit/modules/moz.build
@@ -8,16 +8,18 @@ XPCSHELL_TESTS_MANIFESTS += ['tests/xpcs
 BROWSER_CHROME_MANIFESTS += ['tests/browser/browser.ini']
 MOCHITEST_MANIFESTS += ['tests/mochitest/mochitest.ini']
 MOCHITEST_CHROME_MANIFESTS += ['tests/chrome/chrome.ini']
 
 SPHINX_TREES['toolkit_modules'] = 'docs'
 
 EXTRA_JS_MODULES += [
     'addons/MatchPattern.jsm',
+    'addons/WebNavigation.jsm',
+    'addons/WebNavigationContent.js',
     'addons/WebRequest.jsm',
     'addons/WebRequestCommon.jsm',
     'addons/WebRequestContent.js',
     'Battery.jsm',
     'BinarySearch.jsm',
     'BrowserUtils.jsm',
     'CertUtils.jsm',
     'CharsetMenu.jsm',
--- a/toolkit/modules/tests/browser/browser.ini
+++ b/toolkit/modules/tests/browser/browser.ini
@@ -1,13 +1,16 @@
 [DEFAULT]
 support-files =
   dummy_page.html
   metadata_*.html
   testremotepagemanager.html
+  file_WebNavigation_page1.html
+  file_WebNavigation_page2.html
+  file_WebNavigation_page3.html
   file_WebRequest_page1.html
   file_WebRequest_page2.html
   file_image_good.png
   file_image_bad.png
   file_image_redirect.png
   file_style_good.css
   file_style_bad.css
   file_style_redirect.css
@@ -18,15 +21,16 @@ support-files =
   WebRequest_dynamic.sjs
 
 [browser_Battery.js]
 [browser_Deprecated.js]
 [browser_Finder.js]
 skip-if = e10s # Bug ?????? - test already uses content scripts, but still fails only under e10s.
 [browser_Geometry.js]
 [browser_InlineSpellChecker.js]
+[browser_WebNavigation.js]
 [browser_WebRequest.js]
 [browser_WebRequest_cookies.js]
 [browser_WebRequest_filtering.js]
 [browser_PageMetadata.js]
 [browser_RemotePageManager.js]
 [browser_RemoteWebNavigation.js]
 [browser_Troubleshoot.js]
new file mode 100644
--- /dev/null
+++ b/toolkit/modules/tests/browser/browser_WebNavigation.js
@@ -0,0 +1,140 @@
+"use strict";
+
+const { interfaces: Ci, classes: Cc, utils: Cu, results: Cr } = Components;
+
+let {WebNavigation} = Cu.import("resource://gre/modules/WebNavigation.jsm", {});
+
+const BASE = "http://example.com/browser/toolkit/modules/tests/browser";
+const URL = BASE + "/file_WebNavigation_page1.html";
+const FRAME = BASE + "/file_WebNavigation_page2.html";
+const FRAME2 = BASE + "/file_WebNavigation_page3.html";
+
+const EVENTS = [
+  "onBeforeNavigate",
+  "onCommitted",
+  "onDOMContentLoaded",
+  "onCompleted",
+  "onErrorOccurred",
+  "onReferenceFragmentUpdated",
+];
+
+const REQUIRED = [
+  "onBeforeNavigate",
+  "onCommitted",
+  "onDOMContentLoaded",
+  "onCompleted",
+];
+
+let expectedBrowser;
+let received = [];
+let completedResolve;
+let waitingURL, waitingEvent;
+let rootWindowID;
+
+function gotEvent(event, details)
+{
+  if (!details.url.startsWith(BASE)) {
+    return;
+  }
+  info(`Got ${event} ${details.url} ${details.windowId} ${details.parentWindowId}`);
+
+  is(details.browser, expectedBrowser, "correct <browser> element");
+
+  received.push({url: details.url, event});
+
+  if (typeof(rootWindowID) == "undefined") {
+    rootWindowID = details.windowId;
+  }
+
+  if (details.url == URL) {
+    is(details.windowId, rootWindowID, "root window ID correct");
+  } else {
+    is(details.parentWindowId, rootWindowID, "parent window ID correct");
+    isnot(details.windowId, rootWindowID, "window ID probably okay");
+  }
+
+  isnot(details.windowId, undefined);
+  isnot(details.parentWindowId, undefined);
+
+  if (details.url == waitingURL && event == waitingEvent) {
+    completedResolve();
+  }
+}
+
+function loadViaFrameScript(url, event, script)
+{
+  // Loading via a frame script ensures that the chrome process never
+  // "gets ahead" of frame scripts in non-e10s mode.
+  received = [];
+  waitingURL = url;
+  waitingEvent = event;
+  expectedBrowser.messageManager.loadFrameScript("data:," + script, false);
+  return new Promise(resolve => { completedResolve = resolve; });
+}
+
+add_task(function* webnav_ordering() {
+  let listeners = {};
+  for (let event of EVENTS) {
+    listeners[event] = gotEvent.bind(null, event);
+    WebNavigation[event].addListener(listeners[event]);
+  }
+
+  gBrowser.selectedTab = gBrowser.addTab();
+  let browser = gBrowser.selectedBrowser;
+  expectedBrowser = browser;
+
+  yield BrowserTestUtils.browserLoaded(browser);
+
+  yield loadViaFrameScript(URL, "onCompleted", `content.location = "${URL}";`);
+
+  function checkRequired(url) {
+    for (let event of REQUIRED) {
+      let found = false;
+      for (let r of received) {
+        if (r.url == url && r.event == event) {
+          found = true;
+        }
+      }
+      ok(found, `Received event ${event} from ${url}`);
+    }
+  }
+
+  checkRequired(URL);
+  checkRequired(FRAME);
+
+  function checkBefore(action1, action2) {
+    function find(action) {
+      for (let i = 0; i < received.length; i++) {
+        if (received[i].url == action.url && received[i].event == action.event) {
+          return i;
+        }
+      }
+      return -1;
+    }
+
+    let index1 = find(action1);
+    let index2 = find(action2);
+    ok(index1 != -1, `Action ${JSON.stringify(action1)} happened`);
+    ok(index2 != -1, `Action ${JSON.stringify(action2)} happened`);
+    ok(index1 < index2, `Action ${JSON.stringify(action1)} happened before ${JSON.stringify(action2)}`);
+  }
+
+  checkBefore({url: URL, event: "onCommitted"}, {url: FRAME, event: "onBeforeNavigate"});
+  checkBefore({url: FRAME, event: "onCompleted"}, {url: URL, event: "onCompleted"});
+
+  yield loadViaFrameScript(FRAME2, "onCompleted", `content.frames[0].location = "${FRAME2}";`);
+
+  checkRequired(FRAME2);
+
+  yield loadViaFrameScript(FRAME2 + "#ref", "onReferenceFragmentUpdated",
+                           "content.frames[0].document.getElementById('elt').click();");
+
+  info("Received onReferenceFragmentUpdated from FRAME2");
+
+  gBrowser.removeCurrentTab();
+
+  for (let event of EVENTS) {
+    WebNavigation[event].removeListener(listeners[event]);
+  }
+});
+
new file mode 100644
--- /dev/null
+++ b/toolkit/modules/tests/browser/file_WebNavigation_page1.html
@@ -0,0 +1,9 @@
+<!DOCTYPE HTML>
+
+<html>
+<body>
+
+<iframe src="file_WebNavigation_page2.html" width="200" height="200"></iframe>
+
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/toolkit/modules/tests/browser/file_WebNavigation_page2.html
@@ -0,0 +1,7 @@
+<!DOCTYPE HTML>
+
+<html>
+<body>
+
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/toolkit/modules/tests/browser/file_WebNavigation_page3.html
@@ -0,0 +1,9 @@
+<!DOCTYPE HTML>
+
+<html>
+<body>
+
+<a id="elt" href="file_WebNavigation_page3.html#ref">click me</a>
+
+</body>
+</html>