Bug 1034036 - Part 3: start tracking windows activations to always be aware of their respective order. This allows consumers to iterate over a set of windows in order of appearance (e.g. z-order). r?dao draft
authorMike de Boer <mdeboer@mozilla.com>
Tue, 27 Feb 2018 13:53:18 +0100
changeset 760381 fb0d76f55a20463856c168a8dcd7ab43b33a5a71
parent 760380 eeca669657ede62733dc0cd5df5e22a046732e89
child 760382 408b04570e365d5e359585c287106e0c5f305812
push id100621
push usermdeboer@mozilla.com
push dateTue, 27 Feb 2018 13:59:55 +0000
reviewersdao
bugs1034036
milestone60.0a1
Bug 1034036 - Part 3: start tracking windows activations to always be aware of their respective order. This allows consumers to iterate over a set of windows in order of appearance (e.g. z-order). r?dao Tests are also added here for the legacy `getTopWindow` method to guard against basic regressions. We now start tracking browser windows right after the DOMContentLoaded event, which is earlier than before. We now also assume that any newly tracked window has the focus initially, which is closer to the nsIWindowMediator semantics. MozReview-Commit-ID: 2ZJaUTI6GBH
browser/base/content/browser.js
browser/modules/BrowserWindowTracker.jsm
browser/modules/test/browser/browser.ini
browser/modules/test/browser/browser_BrowserWindowTracker.js
--- a/browser/base/content/browser.js
+++ b/browser/base/content/browser.js
@@ -1190,16 +1190,17 @@ var gBrowserInit = {
     window.QueryInterface(Ci.nsIInterfaceRequestor)
           .getInterface(nsIWebNavigation)
           .QueryInterface(Ci.nsIDocShellTreeItem).treeOwner
           .QueryInterface(Ci.nsIInterfaceRequestor)
           .getInterface(Ci.nsIXULWindow)
           .XULBrowserWindow = window.XULBrowserWindow;
     window.QueryInterface(Ci.nsIDOMChromeWindow).browserDOMWindow =
       new nsBrowserAccess();
+    BrowserWindowTracker.track(window);
 
     let initBrowser =
       document.getAnonymousElementByAttribute(gBrowser, "anonid", "initialBrowser");
 
     // remoteType and sameProcessAsFrameLoader are passed through to
     // updateBrowserRemoteness as part of an options object, which itself defaults
     // to an empty object. So defaulting them to undefined here will cause the
     // default behavior in updateBrowserRemoteness if they don't get set.
@@ -1476,18 +1477,16 @@ var gBrowserInit = {
       document.getElementById("textfieldDirection-swap").hidden = false;
     }
 
     // Setup click-and-hold gestures access to the session history
     // menus if global click-and-hold isn't turned on
     if (!getBoolPref("ui.click_hold_context_menus", false))
       SetClickAndHoldHandlers();
 
-    BrowserWindowTracker.track(window);
-
     PlacesToolbarHelper.init();
 
     ctrlTab.readPref();
     Services.prefs.addObserver(ctrlTab.prefName, ctrlTab);
 
     // The object handling the downloads indicator is initialized here in the
     // delayed startup function, but the actual indicator element is not loaded
     // unless there are downloads to be displayed.
--- a/browser/modules/BrowserWindowTracker.jsm
+++ b/browser/modules/BrowserWindowTracker.jsm
@@ -8,32 +8,32 @@
  */
 
 var EXPORTED_SYMBOLS = ["BrowserWindowTracker"];
 
 ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 ChromeUtils.import("resource://gre/modules/Services.jsm");
 
 // Lazy getters
-XPCOMUtils.defineLazyServiceGetter(this, "_focusManager",
-                                   "@mozilla.org/focus-manager;1",
-                                   "nsIFocusManager");
 XPCOMUtils.defineLazyModuleGetters(this, {
   AppConstants: "resource://gre/modules/AppConstants.jsm",
   PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm"
 });
 
 // Constants
 const TAB_EVENTS = ["TabBrowserInserted", "TabSelect"];
 const WINDOW_EVENTS = ["activate", "unload"];
 const DEBUG = false;
+const DEFAULT_ZINDEX = 0;
 
 // Variables
 var _lastFocusedWindow = null;
 var _lastTopLevelWindowID = 0;
+var _zIndexIncrement = 0;
+var _trackedWindows = new WeakMap();
 
 // Global methods
 function debug(s) {
   if (DEBUG) {
     dump("-*- UpdateTopLevelContentWindowIDHelper: " + s + "\n");
   }
 }
 
@@ -76,34 +76,70 @@ function _handleEvent(aEvent) {
 function _handleMessage(message) {
   let browser = message.target;
   if (message.name === "Browser:Init" &&
       browser === browser.ownerGlobal.gBrowser.selectedBrowser) {
     _updateCurrentContentOuterWindowID(browser);
   }
 }
 
+function _updateZIndex(window) {
+  if (!_trackedWindows.has(window))
+    return;
+  // It's not like we'll ever hit this, but ok. Pathological case for infinitely
+  // long sessions.
+  if (++_zIndexIncrement == Number.MAX_SAFE_INTEGER) {
+    // Restart from `0`.
+    _resetZIndexTracking();
+    ++_zIndexIncrement;
+  }
+  let props = _trackedWindows.get(window);
+  props.zIndex = _zIndexIncrement;
+  _trackedWindows.set(window, props);
+}
+
+function _resetZIndexTracking() {
+  // Restart the increment from '0'.
+  _zIndexIncrement = 0;
+  let windows = [];
+  // Iterate over the list of currently opened windows to take a snapshot of the
+  // current state and rebuild the z-index tracking map with fresh indices.
+  let windowList = Services.wm.getEnumerator("navigator:browser");
+  while (windowList.hasMoreElements()) {
+    let window = windowList.getNext();
+    if (!_trackedWindows.has(window)) {
+      continue;
+    }
+    windows.push([window, _trackedWindows.get(window).zIndex || DEFAULT_ZINDEX]);
+  }
+  // We're not going to take an amount of windows > MAX_SAFE_INTEGER into account,
+  // because at that point the whole browser process will be frozen anyway.
+  // Now we know all the tracked windows and their respective order, so it's time
+  // to rebuild the map with lower numbers as before.
+  for (let [window, ] of windows.sort((a, b) => a[1] - b[1])) {
+    _trackedWindows.set(window, { zIndex: ++_zIndexIncrement });
+  }
+}
+
 // Methods that impact a window. Put into single object for organization.
 var WindowHelper = {
   addWindow(window) {
     // Add event listeners
     TAB_EVENTS.forEach(function(event) {
       window.gBrowser.tabContainer.addEventListener(event, _handleEvent);
     });
     WINDOW_EVENTS.forEach(function(event) {
       window.addEventListener(event, _handleEvent);
     });
+    _trackedWindows.set(window, {});
 
     let messageManager = window.getGroupMessageManager("browsers");
     messageManager.addMessageListener("Browser:Init", _handleMessage);
 
-    // This gets called AFTER activate event, so if this is the focused window
-    // we want to activate it.
-    if (window == _focusManager.activeWindow)
-      this.handleFocusedWindow(window);
+    this.handleFocusedWindow(window);
 
     // Update the selected tab's content outer window ID.
     _updateCurrentContentOuterWindowID(window.gBrowser.selectedBrowser);
   },
 
   removeWindow(window) {
     if (window == _lastFocusedWindow)
       _lastFocusedWindow = null;
@@ -128,16 +164,17 @@ var WindowHelper = {
     this.handleFocusedWindow(window);
 
     _updateCurrentContentOuterWindowID(window.gBrowser.selectedBrowser);
   },
 
   handleFocusedWindow(window) {
     // window is now focused
     _lastFocusedWindow = window;
+    _updateZIndex(window);
   },
 
   getTopWindow(options) {
     let checkPrivacy = typeof options == "object" &&
                        "private" in options;
 
     let allowPopups = typeof options == "object" && !!options.allowPopups;
 
@@ -170,29 +207,68 @@ var WindowHelper = {
     }
     let windowList = Services.wm.getZOrderDOMWindowEnumerator("navigator:browser", true);
     while (windowList.hasMoreElements()) {
       let win = windowList.getNext();
       if (isSuitableBrowserWindow(win))
         return win;
     }
     return null;
-  }
+  },
+
+  orderWindows(windows) {
+    let orderedSet = [];
+    for (let window of windows) {
+      let incr = _trackedWindows.has(window) ?
+        (_trackedWindows.get(window).zIndex || DEFAULT_ZINDEX) : DEFAULT_ZINDEX;
+      orderedSet.push([window, incr]);
+    }
+    // Sort the set in reverse increment, so that the last focused window is the
+    // first item in the array.
+    return orderedSet.sort((a, b) => b[1] - a[1]).map(w => w[0]);
+   }
 };
 
 this.BrowserWindowTracker = {
   /**
    * Get the most recent browser window.
    *
    * @param options an object accepting the arguments for the search.
    *        * private: true to restrict the search to private windows
    *            only, false to restrict the search to non-private only.
    *            Omit the property to search in both groups.
    *        * allowPopups: true if popup windows are permissable.
    */
   getTopWindow(options) {
     return WindowHelper.getTopWindow(options);
   },
 
+  /**
+   * Order an iterable yielding window objects by z-index, in reverse order. This
+   * means that the lastly focused window will the first item in the array that
+   * is returned.
+   * Note: we only know the order of windows we're actively tracking, which
+   * basically means _only_ browser windows. This means that other window types
+   * will be appended to the list.
+   *
+   * @param  {Iterable} windows Set of windows to order by z-index.
+   * @return {Array}    A list of window objects, ordered by z-index, in reverse.
+   *                    The lastly focused window will thusly be located at index 0.
+   */
+  orderWindows(windows) {
+    return WindowHelper.orderWindows(windows);
+  },
+
   track(window) {
     return WindowHelper.addWindow(window);
+  },
+
+  /**
+   * Helper method to mock a specific environment for unit tests.
+   *
+   * @param {Object} env Bag of properties that change the internal behavior and/ or state.
+   */
+  setEnv(env) {
+    if ("zIndexIncrement" in env) {
+      _zIndexIncrement = env.zIndexIncrement;
+    }
   }
 };
--- a/browser/modules/test/browser/browser.ini
+++ b/browser/modules/test/browser/browser.ini
@@ -8,16 +8,17 @@ support-files =
 [browser_BrowserUITelemetry_buckets.js]
 skip-if = !e10s # Bug 1373549
 [browser_BrowserUITelemetry_defaults.js]
 skip-if = !e10s # Bug 1373549
 [browser_BrowserUITelemetry_sidebar.js]
 skip-if = !e10s # Bug 1373549
 [browser_BrowserUITelemetry_syncedtabs.js]
 skip-if = !e10s # Bug 1373549
+[browser_BrowserWindowTracker.js]
 [browser_ContentSearch.js]
 support-files =
   contentSearch.js
   contentSearchBadImage.xml
   contentSearchSuggestions.sjs
   contentSearchSuggestions.xml
   !/browser/components/search/test/head.js
   !/browser/components/search/test/testEngine.xml
new file mode 100644
--- /dev/null
+++ b/browser/modules/test/browser/browser_BrowserWindowTracker.js
@@ -0,0 +1,109 @@
+"use strict";
+
+ChromeUtils.import("resource:///modules/BrowserWindowTracker.jsm");
+ChromeUtils.import("resource://gre/modules/AppConstants.jsm");
+
+const TEST_WINDOWID_PROP = "__TEST_WINDOWID";
+
+async function withOpenWindows(amount, cont) {
+  let windows = [];
+  for (let i = 0; i < amount; ++i) {
+    let window = await BrowserTestUtils.openNewBrowserWindow();
+    window[TEST_WINDOWID_PROP] = i;
+    windows.push(window);
+  }
+  await cont(windows);
+  for (let window of windows) {
+    await BrowserTestUtils.closeWindow(window);
+  }
+}
+
+add_task(async function test_getTopWindow() {
+  await withOpenWindows(5, async function(windows) {
+    // Without options passed in.
+    let window = BrowserWindowTracker.getTopWindow();
+    let expectedMostRecentIndex = windows.length - 1;
+    Assert.equal(window, windows[expectedMostRecentIndex],
+      "Last opened window should be the most recent one.");
+
+    // Mess with the focused window things a bit.
+    for (let idx of [3, 1]) {
+      let promise = BrowserTestUtils.waitForEvent(windows[idx], "activate");
+      windows[idx].focus();
+      await promise;
+      window = BrowserWindowTracker.getTopWindow();
+      Assert.equal(window, windows[idx], "Lastly focused window should be the most recent one.");
+      // For this test it's useful to keep the array of created windows in order.
+      windows.splice(idx, 1);
+      windows.push(window);
+    }
+    // Update the pointer to the most recent opened window.
+    expectedMostRecentIndex = windows.length - 1;
+
+    // With 'private' option.
+    window = BrowserWindowTracker.getTopWindow({ private: true });
+    Assert.equal(window, null, "No private windows opened yet.");
+    window = BrowserWindowTracker.getTopWindow({ private: 1 });
+    Assert.equal(window, null, "No private windows opened yet.");
+    windows.push(await BrowserTestUtils.openNewBrowserWindow({ private: true }));
+    ++expectedMostRecentIndex;
+    window = BrowserWindowTracker.getTopWindow({ private: true });
+    Assert.equal(window, windows[windows.length - 1], "Private window available.");
+    window = BrowserWindowTracker.getTopWindow({ private: 1 });
+    Assert.equal(window, windows[windows.length - 1], "Private window available.");
+
+    // Allow popups.
+    window = BrowserWindowTracker.getTopWindow({ allowPopups: true });
+    Assert.equal(window, windows[expectedMostRecentIndex],
+      "Window focused before the private window should be the most recent one.");
+    window = BrowserWindowTracker.getTopWindow({ allowPopups: false });
+    Assert.equal(window, windows[expectedMostRecentIndex],
+      "Window focused before the private window should be the most recent one.");
+
+    // Somehow on Linux, transforming an existing window to a popup doesn't work.
+    if (AppConstants.platform != "linux") {
+      windows[expectedMostRecentIndex].toolbar.visible = false;
+      --expectedMostRecentIndex;
+      window = BrowserWindowTracker.getTopWindow({ allowPopups: true });
+      Assert.equal(window, windows[expectedMostRecentIndex + 1],
+        "Window focused before the private window should be the most recent one.");
+      window = BrowserWindowTracker.getTopWindow({ allowPopups: false });
+      Assert.equal(window, windows[expectedMostRecentIndex],
+        "Window focused before the private and popup window should be the most recent one.");
+    }
+  });
+});
+
+add_task(async function test_orderWindows() {
+  await withOpenWindows(10, async function(windows) {
+    let ordered = BrowserWindowTracker.orderWindows(windows);
+    Assert.deepEqual([9, 8, 7, 6, 5, 4, 3, 2, 1, 0], ordered.map(w => w[TEST_WINDOWID_PROP]),
+      "Order of opened windows should be as opened.");
+
+    // Mess with the focused window things a bit.
+    for (let idx of [4, 6, 1]) {
+      let promise = BrowserTestUtils.waitForEvent(windows[idx], "activate");
+      windows[idx].focus();
+      await promise;
+    }
+
+    let ordered2 = BrowserWindowTracker.orderWindows(windows);
+    // After the shuffle, we expect window '1' to be the top-most window, because
+    // it was the last one we called focus on. Then '6', the window we focused
+    // before-last, followed by '4'. The order of the other windows remains
+    // unchanged.
+    Assert.deepEqual([1, 6, 4, 9, 8, 7, 5, 3, 2, 0], ordered2.map(w => w[TEST_WINDOWID_PROP]),
+      "After shuffle of focused windows, the order should've changed.");
+
+    // Test that a huge amount of 'activate' events (switching windows) doesn't
+    // cause unexpected behavior.
+    BrowserWindowTracker.setEnv({ zIndexIncrement: Number.MAX_SAFE_INTEGER - 1 });
+    let promise = BrowserTestUtils.waitForEvent(windows[9], "activate");
+    windows[9].focus();
+    await promise;
+
+    let ordered3 = BrowserWindowTracker.orderWindows(windows);
+    Assert.deepEqual([9, 1, 6, 4, 8, 7, 5, 3, 2, 0], ordered3.map(w => w[TEST_WINDOWID_PROP]),
+      "An insane amount of 'activate' events shouldn't confuse the tracker.");
+  });
+});