Bug 1308061 - Implement sessionstore-closed-objects-changed event, r?mikedeboer draft
authorBob Silverberg <bsilverberg@mozilla.com>
Fri, 25 Nov 2016 10:32:08 -0500
changeset 444481 644ce6c25b4abb2dc1920ff143d22d70de711b96
parent 444478 05328d3102efd4d5fc0696489734d7771d24459f
child 538273 79d2513f80393c65872fafeb20e67644fd9bf9b8
push id37241
push userbmo:bob.silverberg@gmail.com
push dateMon, 28 Nov 2016 02:26:26 +0000
reviewersmikedeboer
bugs1308061
milestone53.0a1
Bug 1308061 - Implement sessionstore-closed-objects-changed event, r?mikedeboer MozReview-Commit-ID: 7pBrvAhVQHP
browser/components/sessionstore/SessionStore.jsm
browser/components/sessionstore/test/browser.ini
browser/components/sessionstore/test/browser_closed_objects_changed_notifications_tabs.js
browser/components/sessionstore/test/browser_closed_objects_changed_notifications_windows.js
--- a/browser/components/sessionstore/SessionStore.jsm
+++ b/browser/components/sessionstore/SessionStore.jsm
@@ -21,16 +21,17 @@ const TAB_STATE_WILL_RESTORE = 3;
 // A new window has just been restored. At this stage, tabs are generally
 // not restored.
 const NOTIFY_SINGLE_WINDOW_RESTORED = "sessionstore-single-window-restored";
 const NOTIFY_WINDOWS_RESTORED = "sessionstore-windows-restored";
 const NOTIFY_BROWSER_STATE_RESTORED = "sessionstore-browser-state-restored";
 const NOTIFY_LAST_SESSION_CLEARED = "sessionstore-last-session-cleared";
 const NOTIFY_RESTORING_ON_STARTUP = "sessionstore-restoring-on-startup";
 const NOTIFY_INITIATING_MANUAL_RESTORE = "sessionstore-initiating-manual-restore";
+const NOTIFY_CLOSED_OBJECTS_CHANGED = "sessionstore-closed-objects-changed";
 
 const NOTIFY_TAB_RESTORED = "sessionstore-debug-tab-restored"; // WARNING: debug-only
 
 // Maximum number of tabs to restore simultaneously. Previously controlled by
 // the browser.sessionstore.max_concurrent_tabs pref.
 const MAX_CONCURRENT_TAB_RESTORES = 3;
 
 // Amount (in CSS px) by which we allow window edges to be off-screen
@@ -164,16 +165,18 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 XPCOMUtils.defineLazyModuleGetter(this, "ScratchpadManager",
   "resource://devtools/client/scratchpad/scratchpad-manager.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "SessionSaver",
   "resource:///modules/sessionstore/SessionSaver.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "SessionCookies",
   "resource:///modules/sessionstore/SessionCookies.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "SessionFile",
   "resource:///modules/sessionstore/SessionFile.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "setTimeout",
+  "resource://gre/modules/Timer.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "TabAttributes",
   "resource:///modules/sessionstore/TabAttributes.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "TabCrashHandler",
   "resource:///modules/ContentCrashHandlers.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "TabState",
   "resource:///modules/sessionstore/TabState.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "TabStateCache",
   "resource:///modules/sessionstore/TabStateCache.jsm");
@@ -462,16 +465,19 @@ var SessionStoreInternal = {
   // number of tabs currently restoring
   _tabsRestoringCount: 0,
 
   // When starting Firefox with a single private window, this is the place
   // where we keep the session we actually wanted to restore in case the user
   // decides to later open a non-private window as well.
   _deferredInitialState: null,
 
+  // Keeps track of whether a notification needs to be sent that closed objects have changed.
+  _closedObjectsChanged: false,
+
   // A promise resolved once initialization is complete
   _deferredInitialized: (function () {
     let deferred = {};
 
     deferred.promise = new Promise((resolve, reject) => {
       deferred.resolve = resolve;
       deferred.reject = reject;
     });
@@ -677,39 +683,45 @@ var SessionStoreInternal = {
    * Handle notifications
    */
   observe: function ssi_observe(aSubject, aTopic, aData) {
     switch (aTopic) {
       case "browser-window-before-show": // catch new windows
         this.onBeforeBrowserWindowShown(aSubject);
         break;
       case "domwindowclosed": // catch closed windows
-        this.onClose(aSubject);
+        this.onClose(aSubject).then(() => {
+          this._notifyOfClosedObjectsChange();
+        });
         break;
       case "quit-application-granted":
         let syncShutdown = aData == "syncShutdown";
         this.onQuitApplicationGranted(syncShutdown);
         break;
       case "browser-lastwindow-close-granted":
         this.onLastWindowCloseGranted();
         break;
       case "quit-application":
         this.onQuitApplication(aData);
         break;
       case "browser:purge-session-history": // catch sanitization
         this.onPurgeSessionHistory();
+        this._notifyOfClosedObjectsChange();
         break;
       case "browser:purge-domain-data":
         this.onPurgeDomainData(aData);
+        this._notifyOfClosedObjectsChange();
         break;
       case "nsPref:changed": // catch pref changes
         this.onPrefChange(aData);
+        this._notifyOfClosedObjectsChange();
         break;
       case "idle-daily":
         this.onIdleDaily();
+        this._notifyOfClosedObjectsChange();
         break;
     }
   },
 
   /**
    * This method handles incoming messages sent by the session store content
    * script via the Frame Message Manager or Parent Process Message Manager,
    * and thus enables communication with OOP tabs.
@@ -939,16 +951,17 @@ var SessionStoreInternal = {
         this.onTabBrowserInserted(win, target);
         break;
       case "TabClose":
         // `adoptedBy` will be set if the tab was closed because it is being
         // moved to a new window.
         if (!aEvent.detail.adoptedBy)
           this.onTabClose(win, target);
         this.onTabRemove(win, target);
+        this._notifyOfClosedObjectsChange();
         break;
       case "TabSelect":
         this.onTabSelect(win);
         break;
       case "TabShow":
         this.onTabShow(win, target);
         break;
       case "TabHide":
@@ -1155,28 +1168,28 @@ var SessionStoreInternal = {
           // These are our pinned tabs, which we should restore
           if (appTabsState.windows.length) {
             newWindowState = appTabsState.windows[0];
             delete newWindowState.__lastSessionWindowID;
           }
 
           // In case there were no unpinned tabs, remove the window from _closedWindows
           if (!normalTabsState.windows.length) {
-            this._closedWindows.splice(closedWindowIndex, 1);
+            this._removeClosedWindow(closedWindowIndex);
           }
           // Or update _closedWindows with the modified state
           else {
             delete normalTabsState.windows[0].__lastSessionWindowID;
             this._closedWindows[closedWindowIndex] = normalTabsState.windows[0];
           }
         }
         else {
           // If we're just restoring the window, make sure it gets removed from
           // _closedWindows.
-          this._closedWindows.splice(closedWindowIndex, 1);
+          this._removeClosedWindow(closedWindowIndex);
           newWindowState = closedWindowState;
           delete newWindowState.hidden;
         }
 
         if (newWindowState) {
           // Ensure that the window state isn't hidden
           this._restoreCount = 1;
           let state = { windows: [newWindowState] };
@@ -1261,33 +1274,36 @@ var SessionStoreInternal = {
   },
 
   /**
    * On window close...
    * - remove event listeners from tabs
    * - save all window data
    * @param aWindow
    *        Window reference
+   *
+   * @returns a Promise
    */
   onClose: function ssi_onClose(aWindow) {
+    let completionPromise = Promise.resolve();
     // this window was about to be restored - conserve its original data, if any
     let isFullyLoaded = this._isWindowLoaded(aWindow);
     if (!isFullyLoaded) {
       if (!aWindow.__SSi) {
         aWindow.__SSi = this._generateWindowID();
       }
 
       this._windows[aWindow.__SSi] = this._statesToRestore[aWindow.__SS_restoreID];
       delete this._statesToRestore[aWindow.__SS_restoreID];
       delete aWindow.__SS_restoreID;
     }
 
     // ignore windows not tracked by SessionStore
     if (!aWindow.__SSi || !this._windows[aWindow.__SSi]) {
-      return;
+      return completionPromise;
     }
 
     // notify that the session store will stop tracking this window so that
     // extensions can store any data about this window in session store before
     // that's not possible anymore
     let event = aWindow.document.createEvent("Events");
     event.initEvent("SSWindowClosing", true, false);
     aWindow.dispatchEvent(event);
@@ -1372,17 +1388,17 @@ var SessionStoreInternal = {
       // 3) When the flush is complete, revisit our decision to store the window
       //    in _closedWindows, and add/remove as necessary.
       if (!winData.isPrivate) {
         // Remove any open private tabs the window may contain.
         PrivacyFilter.filterPrivateTabs(winData);
         this.maybeSaveClosedWindow(winData, isLastWindow);
       }
 
-      TabStateFlusher.flushWindow(aWindow).then(() => {
+      completionPromise = TabStateFlusher.flushWindow(aWindow).then(() => {
         // At this point, aWindow is closed! You should probably not try to
         // access any DOM elements from aWindow within this callback unless
         // you're holding on to them in the closure.
 
         for (let browser of browsers) {
           if (this._closedWindowTabs.has(browser.permanentKey)) {
             let tabData = this._closedWindowTabs.get(browser.permanentKey);
             TabState.copyFromCache(browser, tabData);
@@ -1408,16 +1424,18 @@ var SessionStoreInternal = {
       });
     } else {
       this.cleanUpWindow(aWindow, winData, browsers);
     }
 
     for (let i = 0; i < tabbrowser.tabs.length; i++) {
       this.onTabRemove(aWindow, tabbrowser.tabs[i], true);
     }
+
+    return completionPromise;
   },
 
   /**
    * Clean up the message listeners on a window that has finally
    * gone away. Call this once you're sure you don't want to hear
    * from any of this windows tabs from here forward.
    *
    * @param aWindow
@@ -1486,18 +1504,19 @@ var SessionStoreInternal = {
         }
 
         // About to save the closed window, add a unique ID.
         winData.closedId = this._nextClosedId++;
 
         // Insert tabData at the right position.
         this._closedWindows.splice(index, 0, winData);
         this._capClosedWindows();
+        this._closedObjectsChanged = true;
       } else if (!shouldStore && alreadyStored) {
-        this._closedWindows.splice(winIndex, 1);
+        this._removeClosedWindow(winIndex);
       }
     }
   },
 
   /**
    * On quit application granted
    */
   onQuitApplicationGranted: function ssi_onQuitApplicationGranted(syncShutdown=false) {
@@ -1664,23 +1683,29 @@ var SessionStoreInternal = {
 
     let openWindows = {};
     // Collect open windows.
     this._forEachBrowserWindow(({__SSi: id}) => openWindows[id] = true);
 
     // also clear all data about closed tabs and windows
     for (let ix in this._windows) {
       if (ix in openWindows) {
-        this._windows[ix]._closedTabs = [];
+        if (this._windows[ix]._closedTabs.length) {
+          this._windows[ix]._closedTabs = [];
+          this._closedObjectsChanged = true;
+        }
       } else {
         delete this._windows[ix];
       }
     }
     // also clear all data about closed windows
-    this._closedWindows = [];
+    if (this._closedWindows.length) {
+      this._closedWindows = [];
+      this._closedObjectsChanged = true;
+    }
     // give the tabbrowsers a chance to clear their histories first
     var win = this._getMostRecentBrowserWindow();
     if (win) {
       win.setTimeout(() => SessionSaver.run(), 0);
     } else if (RunState.isRunning) {
       SessionSaver.run();
     }
 
@@ -1700,18 +1725,20 @@ var SessionStoreInternal = {
         return true;
       }
       return aEntry.children && aEntry.children.some(containsDomain, this);
     }
     // remove all closed tabs containing a reference to the given domain
     for (let ix in this._windows) {
       let closedTabs = this._windows[ix]._closedTabs;
       for (let i = closedTabs.length - 1; i >= 0; i--) {
-        if (closedTabs[i].state.entries.some(containsDomain, this))
+        if (closedTabs[i].state.entries.some(containsDomain, this)) {
           closedTabs.splice(i, 1);
+          this._closedObjectsChanged = true;
+        }
       }
     }
     // remove all open & closed tabs containing a reference to the given
     // domain in closed windows
     for (let ix = this._closedWindows.length - 1; ix >= 0; ix--) {
       let closedTabs = this._closedWindows[ix]._closedTabs;
       let openTabs = this._closedWindows[ix].tabs;
       let openTabCount = openTabs.length;
@@ -1753,17 +1780,20 @@ var SessionStoreInternal = {
    */
   onPrefChange: function ssi_onPrefChange(aData) {
     switch (aData) {
       // if the user decreases the max number of closed tabs they want
       // preserved update our internal states to match that max
       case "sessionstore.max_tabs_undo":
         this._max_tabs_undo = this._prefBranch.getIntPref("sessionstore.max_tabs_undo");
         for (let ix in this._windows) {
-          this._windows[ix]._closedTabs.splice(this._max_tabs_undo, this._windows[ix]._closedTabs.length);
+          if (this._windows[ix]._closedTabs.length > this._max_tabs_undo) {
+            this._windows[ix]._closedTabs.splice(this._max_tabs_undo, this._windows[ix]._closedTabs.length);
+            this._closedObjectsChanged = true;
+          }
         }
         break;
       case "sessionstore.max_windows_undo":
         this._max_windows_undo = this._prefBranch.getIntPref("sessionstore.max_windows_undo");
         this._capClosedWindows();
         break;
     }
   },
@@ -1909,16 +1939,17 @@ var SessionStoreInternal = {
       index = closedTabs.length;
     }
 
     // About to save the closed tab, add a unique ID.
     tabData.closedId = this._nextClosedId++;
 
     // Insert tabData at the right position.
     closedTabs.splice(index, 0, tabData);
+    this._closedObjectsChanged = true;
 
     // Truncate the list of closed tabs, if needed.
     if (closedTabs.length > this._max_tabs_undo) {
       closedTabs.splice(this._max_tabs_undo, closedTabs.length);
     }
   },
 
   /**
@@ -1929,16 +1960,17 @@ var SessionStoreInternal = {
    * @param closedTabs (array)
    *        The list of closed tabs for a window.
    * @param index (uint)
    *        The index of the tab to remove.
    */
   removeClosedTabData(closedTabs, index) {
     // Remove the given index from the list.
     let [closedTab] = closedTabs.splice(index, 1);
+    this._closedObjectsChanged = true;
 
     // If the closed tab's state still has a .permanentKey property then we
     // haven't seen its final update message yet. Remove it from the map of
     // closed tabs so that we will simply discard its last messages and will
     // not add it back to the list of closed tabs again.
     if (closedTab.permanentKey) {
       this._closedTabs.delete(closedTab.permanentKey);
       this._closedWindowTabs.delete(closedTab.permanentKey);
@@ -2058,32 +2090,35 @@ var SessionStoreInternal = {
     // Remove old closed windows
     this._cleanupOldData([this._closedWindows]);
 
     // Remove closed tabs of closed windows
     this._cleanupOldData(this._closedWindows.map((winData) => winData._closedTabs));
 
     // Remove closed tabs of open windows
     this._cleanupOldData(Object.keys(this._windows).map((key) => this._windows[key]._closedTabs));
+
+    this._notifyOfClosedObjectsChange();
   },
 
   // Remove "old" data from an array
   _cleanupOldData: function(targets) {
     const TIME_TO_LIVE = this._prefBranch.getIntPref("sessionstore.cleanup.forget_closed_after");
     const now = Date.now();
 
     for (let array of targets) {
       for (let i = array.length - 1; i >= 0; --i)  {
         let data = array[i];
         // Make sure that we have a timestamp to tell us when the target
         // has been closed. If we don't have a timestamp, default to a
         // safe timestamp: just now.
         data.closedAt = data.closedAt || now;
         if (now - data.closedAt > TIME_TO_LIVE) {
           array.splice(i, 1);
+          this._closedObjectsChanged = true;
         }
       }
     }
   },
 
   /* ........ nsISessionStore API .............. */
 
   getBrowserState: function ssi_getBrowserState() {
@@ -2128,27 +2163,33 @@ var SessionStoreInternal = {
     this._forEachBrowserWindow(function(aWindow) {
       if (aWindow != window) {
         aWindow.close();
         this.onClose(aWindow);
       }
     });
 
     // make sure closed window data isn't kept
-    this._closedWindows = [];
+    if (this._closedWindows.length) {
+      this._closedWindows = [];
+      this._closedObjectsChanged = true;
+    }
 
     // determine how many windows are meant to be restored
     this._restoreCount = state.windows ? state.windows.length : 0;
 
     // global data must be restored before restoreWindow is called so that
     // it happens before observers are notified
     this._globalState.setFromState(state);
 
     // restore to the given state
     this.restoreWindows(window, state, {overwriteTabs: true});
+
+    // Notify of changes to closed objects.
+    this._notifyOfClosedObjectsChange();
   },
 
   getWindowState: function ssi_getWindowState(aWindow) {
     if ("__SSi" in aWindow) {
       return JSON.stringify(this._getWindowState(aWindow));
     }
 
     if (DyingWindowCache.has(aWindow)) {
@@ -2160,16 +2201,19 @@ var SessionStoreInternal = {
   },
 
   setWindowState: function ssi_setWindowState(aWindow, aState, aOverwrite) {
     if (!aWindow.__SSi) {
       throw Components.Exception("Window is not tracked", Cr.NS_ERROR_INVALID_ARG);
     }
 
     this.restoreWindows(aWindow, aState, {overwriteTabs: aOverwrite});
+
+    // Notify of changes to closed objects.
+    this._notifyOfClosedObjectsChange();
   },
 
   getTabState: function ssi_getTabState(aTab) {
     if (!aTab.ownerGlobal.__SSi) {
       throw Components.Exception("Default view is not tracked", Cr.NS_ERROR_INVALID_ARG);
     }
 
     let tabState = TabState.collect(aTab);
@@ -2198,16 +2242,19 @@ var SessionStoreInternal = {
       throw Components.Exception("Window is not tracked", Cr.NS_ERROR_INVALID_ARG);
     }
 
     if (aTab.linkedBrowser.__SS_restoreState) {
       this._resetTabRestoringState(aTab);
     }
 
     this.restoreTab(aTab, tabState);
+
+    // Notify of changes to closed objects.
+    this._notifyOfClosedObjectsChange();
   },
 
   duplicateTab: function ssi_duplicateTab(aWindow, aTab, aDelta = 0, aRestoreImmediately = true) {
     if (!aTab.ownerGlobal.__SSi) {
       throw Components.Exception("Default view is not tracked", Cr.NS_ERROR_INVALID_ARG);
     }
     if (!aWindow.gBrowser) {
       throw Components.Exception("Invalid window object: no gBrowser", Cr.NS_ERROR_INVALID_ARG);
@@ -2312,16 +2359,19 @@ var SessionStoreInternal = {
     this.restoreTab(tab, state);
 
     // restore the tab's position
     tabbrowser.moveTabTo(tab, pos);
 
     // focus the tab's content area (bug 342432)
     tab.linkedBrowser.focus();
 
+    // Notify of changes to closed objects.
+    this._notifyOfClosedObjectsChange();
+
     return tab;
   },
 
   forgetClosedTab: function ssi_forgetClosedTab(aWindow, aIndex) {
     if (!aWindow.__SSi) {
       throw Components.Exception("Window is not tracked", Cr.NS_ERROR_INVALID_ARG);
     }
 
@@ -2330,51 +2380,61 @@ var SessionStoreInternal = {
     // default to the most-recently closed tab
     aIndex = aIndex || 0;
     if (!(aIndex in closedTabs)) {
       throw Components.Exception("Invalid index: not in the closed tabs", Cr.NS_ERROR_INVALID_ARG);
     }
 
     // remove closed tab from the array
     this.removeClosedTabData(closedTabs, aIndex);
+
+    // Notify of changes to closed objects.
+    this._notifyOfClosedObjectsChange();
   },
 
   getClosedWindowCount: function ssi_getClosedWindowCount() {
     return this._closedWindows.length;
   },
 
   getClosedWindowData: function ssi_getClosedWindowData(aAsString = true) {
     return aAsString ? JSON.stringify(this._closedWindows) : Cu.cloneInto(this._closedWindows, {});
   },
 
   undoCloseWindow: function ssi_undoCloseWindow(aIndex) {
     if (!(aIndex in this._closedWindows)) {
       throw Components.Exception("Invalid index: not in the closed windows", Cr.NS_ERROR_INVALID_ARG);
     }
 
     // reopen the window
-    let state = { windows: this._closedWindows.splice(aIndex, 1) };
+    let state = { windows: this._removeClosedWindow(aIndex) };
     delete state.windows[0].closedAt; // Window is now open.
 
     let window = this._openWindowWithState(state);
     this.windowToFocus = window;
+
+    // Notify of changes to closed objects.
+    this._notifyOfClosedObjectsChange();
+
     return window;
   },
 
   forgetClosedWindow: function ssi_forgetClosedWindow(aIndex) {
     // default to the most-recently closed window
     aIndex = aIndex || 0;
     if (!(aIndex in this._closedWindows)) {
       throw Components.Exception("Invalid index: not in the closed windows", Cr.NS_ERROR_INVALID_ARG);
     }
 
     // remove closed window from the array
     let winData = this._closedWindows[aIndex];
-    this._closedWindows.splice(aIndex, 1);
+    this._removeClosedWindow(aIndex);
     this._saveableClosedWindowData.delete(winData);
+
+    // Notify of changes to closed objects.
+    this._notifyOfClosedObjectsChange();
   },
 
   getWindowValue: function ssi_getWindowValue(aWindow, aKey) {
     if ("__SSi" in aWindow) {
       var data = this._windows[aWindow.__SSi].extData || {};
       return data[aKey] || "";
     }
 
@@ -2579,30 +2639,34 @@ var SessionStoreInternal = {
         this._openWindowWithState({ windows: [winState] });
       }
     }
 
     // Merge closed windows from this session with ones from last session
     if (lastSessionState._closedWindows) {
       this._closedWindows = this._closedWindows.concat(lastSessionState._closedWindows);
       this._capClosedWindows();
+      this._closedObjectsChanged = true;
     }
 
     if (lastSessionState.scratchpads) {
       ScratchpadManager.restoreSession(lastSessionState.scratchpads);
     }
 
     // Set data that persists between sessions
     this._recentCrashes = lastSessionState.session &&
                           lastSessionState.session.recentCrashes || 0;
 
     // Update the session start time using the restored session state.
     this._updateSessionStartTime(lastSessionState);
 
     LastSession.clear();
+
+    // Notify of changes to closed objects.
+    this._notifyOfClosedObjectsChange();
   },
 
   /**
    * Revive a crashed tab and restore its state from before it crashed.
    *
    * @param aTab
    *        A <xul:tab> linked to a crashed browser. This is a no-op if the
    *        browser hasn't actually crashed, or is not associated with a tab.
@@ -2730,16 +2794,19 @@ var SessionStoreInternal = {
         this._resetLocalTabRestoringState(tab);
       }
 
       // Restore the state into the tab.
       this.restoreTab(tab, tabState, options);
     });
 
     tab.linkedBrowser.__SS_restoreState = TAB_STATE_WILL_RESTORE;
+
+    // Notify of changes to closed objects.
+    this._notifyOfClosedObjectsChange();
   },
 
   /**
    * Retrieves the latest session history information for a tab. The cached data
    * is returned immediately, but a callback may be provided that supplies
    * up-to-date data when or if it is available. The callback is passed a single
    * argument with data in the same format as the return value.
    *
@@ -3274,16 +3341,17 @@ var SessionStoreInternal = {
       debug(ex);
       this._sendRestoreCompletedNotifications();
       return;
     }
 
     // Restore closed windows if any.
     if (root._closedWindows) {
       this._closedWindows = root._closedWindows;
+      this._closedObjectsChanged = true;
     }
 
     // We're done here if there are no windows.
     if (!root.windows || !root.windows.length) {
       this._sendRestoreCompletedNotifications();
       return;
     }
 
@@ -3777,16 +3845,45 @@ var SessionStoreInternal = {
     }
 
     SessionSaver.runDelayed();
   },
 
   /* ........ Auxiliary Functions .............. */
 
   /**
+   * Remove a closed window from the list of closed windows and indicate that
+   * the change should be notified.
+   *
+   * @param index
+   *        The index of the window in this._closedWindows.
+   *
+   * @returns Array of closed windows.
+   */
+  _removeClosedWindow(index) {
+    let windows = this._closedWindows.splice(index, 1);
+    this._closedObjectsChanged = true;
+    return windows;
+  },
+
+  /**
+   * Notifies observers that the list of closed tabs and/or windows has changed.
+   * Waits a tick to allow SessionStorage a chance to register the change.
+   */
+  _notifyOfClosedObjectsChange() {
+    if (!this._closedObjectsChanged) {
+      return;
+    }
+    this._closedObjectsChanged = false;
+    setTimeout(() => {
+      Services.obs.notifyObservers(null, NOTIFY_CLOSED_OBJECTS_CHANGED, null);
+    }, 0);
+  },
+
+  /**
    * Determines whether or not a tab that is being restored needs
    * to have its remoteness flipped first.
    *
    * @param (object) with the following properties:
    *
    *        tabbrowser (<xul:tabbrowser>):
    *          The tabbrowser that the browser belongs to.
    *
@@ -4338,17 +4435,20 @@ var SessionStoreInternal = {
       let normalWindowIndex = 0;
       // try to find a non-popup window in this._closedWindows
       while (normalWindowIndex < this._closedWindows.length &&
              !!this._closedWindows[normalWindowIndex].isPopup)
         normalWindowIndex++;
       if (normalWindowIndex >= this._max_windows_undo)
         spliceTo = normalWindowIndex + 1;
     }
-    this._closedWindows.splice(spliceTo, this._closedWindows.length);
+    if (spliceTo < this._closedWindows.length) {
+      this._closedWindows.splice(spliceTo, this._closedWindows.length);
+      this._closedObjectsChanged = true;
+    }
   },
 
   /**
    * Clears the set of windows that are "resurrected" before writing to disk to
    * make closing windows one after the other until shutdown work as expected.
    *
    * This function should only be called when we are sure that there has been
    * a user action that indicates the browser is actively being used and all
--- a/browser/components/sessionstore/test/browser.ini
+++ b/browser/components/sessionstore/test/browser.ini
@@ -235,8 +235,11 @@ run-if = e10s
 run-if = e10s && crashreporter
 
 # Disabled on debug for frequent intermittent failures:
 [browser_undoCloseById.js]
 skip-if = debug
 [browser_docshell_uuid_consistency.js]
 [browser_grouped_session_store.js]
 skip-if = !e10s # GroupedSHistory is e10s-only
+
+[browser_closed_objects_changed_notifications_tabs.js]
+[browser_closed_objects_changed_notifications_windows.js]
new file mode 100644
--- /dev/null
+++ b/browser/components/sessionstore/test/browser_closed_objects_changed_notifications_tabs.js
@@ -0,0 +1,116 @@
+"use strict";
+
+/**
+ * This test is for the sessionstore-closed-objects-changed notifications.
+ */
+
+const MAX_TABS_UNDO_PREF = "browser.sessionstore.max_tabs_undo";
+const TOPIC = "sessionstore-closed-objects-changed";
+
+let notificationsCount = 0;
+
+function* openWindow(url) {
+  let win = yield promiseNewWindowLoaded();
+  let flags = Ci.nsIWebNavigation.LOAD_FLAGS_REPLACE_HISTORY;
+  win.gBrowser.selectedBrowser.loadURIWithFlags(url, flags);
+  yield promiseBrowserLoaded(win.gBrowser.selectedBrowser, true, url);
+  return win;
+}
+
+function* closeWindow(win) {
+  yield awaitNotification(() => BrowserTestUtils.closeWindow(win));
+}
+
+function* openAndCloseWindow(url) {
+  let win = yield openWindow(url);
+  yield closeWindow(win);
+}
+
+function* openTab(window, url) {
+  let tab = yield BrowserTestUtils.openNewForegroundTab(window.gBrowser, url);
+  yield TabStateFlusher.flush(tab.linkedBrowser);
+  return tab;
+}
+
+function* openAndCloseTab(window, url) {
+  let tab = yield openTab(window, url);
+  yield promiseRemoveTab(tab);
+}
+
+function countingObserver() {
+  notificationsCount++;
+}
+
+function assertNotificationCount(count) {
+  is(notificationsCount, count, "The expected number of notifications was received.");
+}
+
+function* awaitNotification(callback) {
+  let awaitNotification = TestUtils.topicObserved(TOPIC);
+  executeSoon(callback);
+  yield awaitNotification;
+}
+
+add_task(function* test_closedObjectsChangedNotifications() {
+  // Create a closed window so that when we do the purge we can expect a notification.
+  yield openAndCloseWindow("about:robots");
+
+  // Forget any previous closed windows or tabs from other tests that may have
+  // run in the same session.
+  yield awaitNotification(() => Services.obs.notifyObservers(null, "browser:purge-session-history", 0));
+
+  // Add an observer to count the number of notifications.
+  Services.obs.addObserver(countingObserver, TOPIC, false);
+
+  // Open a new window.
+  let win = yield openWindow("about:robots");
+
+  info("Opening and closing a tab.");
+  yield openAndCloseTab(win, "about:mozilla");
+  assertNotificationCount(1);
+
+  info("Opening and closing a second tab.");
+  yield openAndCloseTab(win, "about:mozilla");
+  assertNotificationCount(2);
+
+  info(`Changing the ${MAX_TABS_UNDO_PREF} pref.`);
+  registerCleanupFunction(function () {
+    Services.prefs.clearUserPref(MAX_TABS_UNDO_PREF);
+  });
+  yield awaitNotification(() => Services.prefs.setIntPref(MAX_TABS_UNDO_PREF, 1));
+  assertNotificationCount(3);
+
+  info("Undoing close of remaining closed tab.");
+  let tab = SessionStore.undoCloseTab(win, 0);
+  yield promiseTabRestored(tab);
+  assertNotificationCount(4);
+
+  info("Closing tab again.");
+  yield promiseRemoveTab(tab);
+  assertNotificationCount(5);
+
+  info("Purging session history.");
+  yield awaitNotification(() => Services.obs.notifyObservers(null, "browser:purge-session-history", 0));
+  assertNotificationCount(6);
+
+  info("Opening and closing another tab.");
+  yield openAndCloseTab(win, "http://example.com/");
+  assertNotificationCount(7);
+
+  info("Purging domain data with no matches.")
+  Services.obs.notifyObservers(null, "browser:purge-domain-data", "mozilla.com");
+  assertNotificationCount(7);
+
+  info("Purging domain data with matches.")
+  yield awaitNotification(() => Services.obs.notifyObservers(null, "browser:purge-domain-data", "example.com"));
+  assertNotificationCount(8);
+
+  info("Opening and closing another tab.");
+  yield openAndCloseTab(win, "http://example.com/");
+  assertNotificationCount(9);
+
+  yield closeWindow(win);
+  assertNotificationCount(10);
+
+  Services.obs.removeObserver(countingObserver, TOPIC);
+});
new file mode 100644
--- /dev/null
+++ b/browser/components/sessionstore/test/browser_closed_objects_changed_notifications_windows.js
@@ -0,0 +1,117 @@
+"use strict";
+
+/**
+ * This test is for the sessionstore-closed-objects-changed notifications.
+ */
+
+requestLongerTimeout(2);
+
+const MAX_WINDOWS_UNDO_PREF = "browser.sessionstore.max_windows_undo";
+const TOPIC = "sessionstore-closed-objects-changed";
+
+let notificationsCount = 0;
+
+function* openWindow(url) {
+  let win = yield promiseNewWindowLoaded();
+  let flags = Ci.nsIWebNavigation.LOAD_FLAGS_REPLACE_HISTORY;
+  win.gBrowser.selectedBrowser.loadURIWithFlags(url, flags);
+  yield promiseBrowserLoaded(win.gBrowser.selectedBrowser, true, url);
+  return win;
+}
+
+function* closeWindow(win) {
+  yield awaitNotification(() => BrowserTestUtils.closeWindow(win));
+}
+
+function* openAndCloseWindow(url) {
+  let win = yield openWindow(url);
+  yield closeWindow(win);
+}
+
+function countingObserver() {
+  notificationsCount++;
+}
+
+function assertNotificationCount(count) {
+  is(notificationsCount, count, "The expected number of notifications was received.");
+}
+
+function* awaitNotification(callback) {
+  let awaitNotification = TestUtils.topicObserved(TOPIC);
+  executeSoon(callback);
+  yield awaitNotification;
+}
+
+add_task(function* test_closedObjectsChangedNotifications() {
+  // Create a closed window so that when we do the purge we know to expect a notification
+  yield openAndCloseWindow("about:robots");
+
+  // Forget any previous closed windows or tabs from other tests that may have
+  // run in the same session.
+  yield awaitNotification(() => Services.obs.notifyObservers(null, "browser:purge-session-history", 0));
+
+  // Add an observer to count the number of notifications.
+  Services.obs.addObserver(countingObserver, TOPIC, false);
+
+  info("Opening and closing initial window.");
+  yield openAndCloseWindow("about:robots");
+  assertNotificationCount(1);
+
+  // Store state with a single closed window for use in later tests.
+  let closedState = Cu.cloneInto(JSON.parse(ss.getBrowserState()), {});
+
+  info("Undoing close of initial window.");
+  let win = SessionStore.undoCloseWindow(0);
+  yield promiseDelayedStartupFinished(win);
+  assertNotificationCount(2);
+
+  // Open a second window.
+  let win2 = yield openWindow("about:mozilla");
+
+  info("Closing both windows.");
+  yield closeWindow(win);
+  assertNotificationCount(3);
+  yield closeWindow(win2);
+  assertNotificationCount(4);
+
+  info(`Changing the ${MAX_WINDOWS_UNDO_PREF} pref.`);
+  registerCleanupFunction(function () {
+    Services.prefs.clearUserPref(MAX_WINDOWS_UNDO_PREF);
+  });
+  yield awaitNotification(() => Services.prefs.setIntPref(MAX_WINDOWS_UNDO_PREF, 1));
+  assertNotificationCount(5);
+
+  info("Forgetting a closed window.");
+  yield awaitNotification(() => SessionStore.forgetClosedWindow());
+  assertNotificationCount(6);
+
+  info("Opening and closing another window.");
+  yield openAndCloseWindow("about:robots");
+  assertNotificationCount(7);
+
+  info("Setting browser state to trigger change onIdleDaily.")
+  let state = Cu.cloneInto(JSON.parse(ss.getBrowserState()), {});
+  state._closedWindows[0].closedAt = 1;
+  yield promiseBrowserState(state);
+  assertNotificationCount(8);
+
+  info("Sending idle-daily");
+  yield awaitNotification(() => Services.obs.notifyObservers(null, "idle-daily", ""));
+  assertNotificationCount(9);
+
+  info("Opening and closing another window.");
+  yield openAndCloseWindow("about:robots");
+  assertNotificationCount(10);
+
+  info("Purging session history.");
+  yield awaitNotification(() => Services.obs.notifyObservers(null, "browser:purge-session-history", 0));
+  assertNotificationCount(11);
+
+  info("Setting window state.")
+  win = yield openWindow("about:mozilla");
+  yield awaitNotification(() => SessionStore.setWindowState(win, closedState));
+  assertNotificationCount(12);
+
+  Services.obs.removeObserver(countingObserver, TOPIC);
+  yield BrowserTestUtils.closeWindow(win);
+});