Bug 1678618: Apply page-removed event. r=mak
☠☠ backed out by ffed7ebd405a ☠ ☠
authorDaisuke Akatsuka <daisuke@birchill.co.jp>
Mon, 15 Feb 2021 02:47:38 +0000
changeset 567467 bef0ea72ded8c40f20b8fa2c9d8158c9260fe993
parent 567466 ec96259a3f818061d2414f3bfbf714683d3f8b08
child 567468 ae83fda183dd0d9dc375edbf624c88caa5d1e62e
push id38204
push usermalexandru@mozilla.com
push dateMon, 15 Feb 2021 09:31:20 +0000
treeherdermozilla-central@ffed7ebd405a [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmak
bugs1678618
milestone87.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 1678618: Apply page-removed event. r=mak Depends on D101114 Differential Revision: https://phabricator.services.mozilla.com/D101115
browser/components/downloads/test/browser/browser_library_clearall.js
browser/components/extensions/parent/ext-history.js
browser/components/extensions/test/xpcshell/test_ext_history.js
browser/components/newtab/lib/PlacesFeed.jsm
browser/components/newtab/test/unit/lib/PlacesFeed.test.js
browser/components/places/tests/browser/browser_library_commands.js
browser/components/places/tests/chrome/test_bug549192.xhtml
browser/components/urlbar/tests/browser/browser_remove_match.js
services/sync/modules/engines/history.js
services/sync/tests/unit/head_helpers.js
toolkit/components/downloads/DownloadHistory.jsm
toolkit/components/downloads/DownloadIntegration.jsm
toolkit/components/places/History.jsm
toolkit/components/places/PlacesExpiration.jsm
toolkit/components/places/nsNavHistoryResult.cpp
toolkit/components/places/tests/expiration/test_notifications_onDeleteURI.js
toolkit/components/places/tests/expiration/test_notifications_onDeleteVisits.js
toolkit/components/places/tests/expiration/test_notifications_pageRemoved_allVisits.js
toolkit/components/places/tests/expiration/test_notifications_pageRemoved_fromStore.js
toolkit/components/places/tests/expiration/test_pref_maxpages.js
toolkit/components/places/tests/expiration/xpcshell.ini
toolkit/components/places/tests/history/test_remove.js
toolkit/components/places/tests/history/test_removeByFilter.js
toolkit/components/places/tests/history/test_removeMany.js
toolkit/components/places/tests/unit/test_history_observer.js
toolkit/components/thumbnails/PageThumbs.jsm
toolkit/modules/NewTabUtils.jsm
--- a/browser/components/downloads/test/browser/browser_library_clearall.js
+++ b/browser/components/downloads/test/browser/browser_library_clearall.js
@@ -45,25 +45,29 @@ async function testClearingDownloads(cle
   let listbox = win.document.getElementById("downloadsRichListBox");
   ok(listbox, "download list box present");
 
   let promiseLength = waitForChildrenLength(listbox, DOWNLOAD_DATA.length);
   await simulateDropAndCheck(win, listbox, DOWNLOAD_DATA);
   await promiseLength;
 
   let receivedNotifications = [];
-  let promiseNotification = PlacesTestUtils.waitForNotification(
-    "onDeleteURI",
-    uri => {
-      if (DOWNLOAD_DATA.includes(uri.spec)) {
-        receivedNotifications.push(uri.spec);
+  const promiseNotification = PlacesTestUtils.waitForNotification(
+    "page-removed",
+    events => {
+      for (const { url, isRemovedFromStore } of events) {
+        Assert.ok(isRemovedFromStore);
+
+        if (DOWNLOAD_DATA.includes(url)) {
+          receivedNotifications.push(url);
+        }
       }
       return receivedNotifications.length == DOWNLOAD_DATA.length;
     },
-    "history"
+    "places"
   );
 
   promiseLength = waitForChildrenLength(listbox, 0);
   await clearCallback(listbox);
   await promiseLength;
 
   await promiseNotification;
 
--- a/browser/components/extensions/parent/ext-history.js
+++ b/browser/components/extensions/parent/ext-history.js
@@ -256,28 +256,47 @@ this.history = class extends ExtensionAP
         onVisitRemoved: new EventManager({
           context,
           name: "history.onVisitRemoved",
           register: fire => {
             let listener = (event, data) => {
               fire.sync(data);
             };
             const historyClearedListener = events => {
-              fire.sync({ allHistory: true, urls: [] });
+              const removedURLs = [];
+
+              for (const event of events) {
+                switch (event.type) {
+                  case "history-cleared": {
+                    fire.sync({ allHistory: true, urls: [] });
+                    break;
+                  }
+                  case "page-removed": {
+                    if (!event.isPartialVisistsRemoval) {
+                      removedURLs.push(event.url);
+                    }
+                    break;
+                  }
+                }
+              }
+
+              if (removedURLs.length) {
+                fire.sync({ allHistory: false, urls: removedURLs });
+              }
             };
 
             getHistoryObserver().on("visitRemoved", listener);
             PlacesUtils.observers.addListener(
-              ["history-cleared"],
+              ["history-cleared", "page-removed"],
               historyClearedListener
             );
             return () => {
               getHistoryObserver().off("visitRemoved", listener);
               PlacesUtils.observers.removeListener(
-                ["history-cleared"],
+                ["history-cleared", "page-removed"],
                 historyClearedListener
               );
             };
           },
         }).api(),
 
         onTitleChanged: new EventManager({
           context,
--- a/browser/components/extensions/test/xpcshell/test_ext_history.js
+++ b/browser/components/extensions/test/xpcshell/test_ext_history.js
@@ -27,22 +27,17 @@ add_task(async function test_delete() {
       if (data.allHistory) {
         historyClearedCount++;
         browser.test.assertEq(
           0,
           data.urls.length,
           "onVisitRemoved received an empty urls array"
         );
       } else {
-        browser.test.assertEq(
-          1,
-          data.urls.length,
-          "onVisitRemoved received one URL"
-        );
-        removedUrls.push(data.urls[0]);
+        removedUrls.push(...data.urls);
       }
     });
 
     browser.test.onMessage.addListener((msg, arg) => {
       if (msg === "delete-url") {
         browser.history.deleteUrl({ url: arg }).then(result => {
           browser.test.assertEq(
             undefined,
--- a/browser/components/newtab/lib/PlacesFeed.jsm
+++ b/browser/components/newtab/lib/PlacesFeed.jsm
@@ -111,23 +111,33 @@ class PlacesObserver extends Observer {
 
     for (const {
       itemType,
       source,
       dateAdded,
       guid,
       title,
       url,
+      isRemovedFromStore,
       isTagging,
       type,
     } of events) {
       switch (type) {
         case "history-cleared":
           this.dispatch({ type: at.PLACES_HISTORY_CLEARED });
           break;
+        case "page-removed":
+          if (isRemovedFromStore) {
+            this.dispatch({ type: at.PLACES_LINKS_CHANGED });
+            this.dispatch({
+              type: at.PLACES_LINK_DELETED,
+              data: { url },
+            });
+          }
+          break;
         case "bookmark-added":
           // Skips items that are not bookmarks (like folders), about:* pages or
           // default bookmarks, added when the profile is created.
           if (
             isTagging ||
             itemType !== PlacesUtils.bookmarks.TYPE_BOOKMARK ||
             source === PlacesUtils.bookmarks.SOURCES.IMPORT ||
             source === PlacesUtils.bookmarks.SOURCES.RESTORE ||
@@ -187,17 +197,17 @@ class PlacesFeed {
     // NB: Directly get services without importing the *BIG* PlacesUtils module
     Cc["@mozilla.org/browser/nav-history-service;1"]
       .getService(Ci.nsINavHistoryService)
       .addObserver(this.historyObserver, true);
     Cc["@mozilla.org/browser/nav-bookmarks-service;1"]
       .getService(Ci.nsINavBookmarksService)
       .addObserver(this.bookmarksObserver, true);
     PlacesUtils.observers.addListener(
-      ["bookmark-added", "bookmark-removed", "history-cleared"],
+      ["bookmark-added", "bookmark-removed", "history-cleared", "page-removed"],
       this.placesObserver.handlePlacesEvent
     );
 
     Services.obs.addObserver(this, LINK_BLOCKED_EVENT);
   }
 
   /**
    * setTimeout - A custom function that creates an nsITimer that can be cancelled
@@ -231,17 +241,17 @@ class PlacesFeed {
   removeObservers() {
     if (this.placesChangedTimer) {
       this.placesChangedTimer.cancel();
       this.placesChangedTimer = null;
     }
     PlacesUtils.history.removeObserver(this.historyObserver);
     PlacesUtils.bookmarks.removeObserver(this.bookmarksObserver);
     PlacesUtils.observers.removeListener(
-      ["bookmark-added", "bookmark-removed", "history-cleared"],
+      ["bookmark-added", "bookmark-removed", "history-cleared", "page-removed"],
       this.placesObserver.handlePlacesEvent
     );
     Services.obs.removeObserver(this, LINK_BLOCKED_EVENT);
   }
 
   /**
    * observe - An observer for the LINK_BLOCKED_EVENT.
    *           Called when a link is blocked.
--- a/browser/components/newtab/test/unit/lib/PlacesFeed.test.js
+++ b/browser/components/newtab/test/unit/lib/PlacesFeed.test.js
@@ -122,17 +122,22 @@ describe("PlacesFeed", () => {
       );
       assert.calledWith(
         global.PlacesUtils.bookmarks.addObserver,
         feed.bookmarksObserver,
         true
       );
       assert.calledWith(
         global.PlacesUtils.observers.addListener,
-        ["bookmark-added", "bookmark-removed", "history-cleared"],
+        [
+          "bookmark-added",
+          "bookmark-removed",
+          "history-cleared",
+          "page-removed",
+        ],
         feed.placesObserver.handlePlacesEvent
       );
       assert.calledWith(global.Services.obs.addObserver, feed, BLOCKED_EVENT);
     });
     it("should remove bookmark, history, places, blocked observers, and timers on UNINIT", () => {
       feed.placesChangedTimer = global.Cc[
         "@mozilla.org/timer;1"
       ].createInstance();
@@ -144,17 +149,22 @@ describe("PlacesFeed", () => {
         feed.historyObserver
       );
       assert.calledWith(
         global.PlacesUtils.bookmarks.removeObserver,
         feed.bookmarksObserver
       );
       assert.calledWith(
         global.PlacesUtils.observers.removeListener,
-        ["bookmark-added", "bookmark-removed", "history-cleared"],
+        [
+          "bookmark-added",
+          "bookmark-removed",
+          "history-cleared",
+          "page-removed",
+        ],
         feed.placesObserver.handlePlacesEvent
       );
       assert.calledWith(
         global.Services.obs.removeObserver,
         feed,
         BLOCKED_EVENT
       );
       assert.equal(feed.placesChangedTimer, null);
@@ -747,26 +757,16 @@ describe("PlacesFeed", () => {
     let observer;
     beforeEach(() => {
       dispatch = sandbox.spy();
       observer = new HistoryObserver(dispatch);
     });
     it("should have a QueryInterface property", () => {
       assert.property(observer, "QueryInterface");
     });
-    describe("#onDeleteURI", () => {
-      it("should dispatch a PLACES_LINK_DELETED action with the right url", async () => {
-        await observer.onDeleteURI({ spec: "foo.com" });
-
-        assert.calledWith(dispatch, {
-          type: at.PLACES_LINK_DELETED,
-          data: { url: "foo.com" },
-        });
-      });
-    });
     describe("Other empty methods (to keep code coverage happy)", () => {
       it("should have a various empty functions for xpconnect happiness", () => {
         observer.onBeginUpdateBatch();
         observer.onEndUpdateBatch();
         observer.onDeleteVisits();
       });
     });
   });
@@ -845,16 +845,27 @@ describe("PlacesFeed", () => {
     describe("#history-cleared", () => {
       it("should dispatch a PLACES_HISTORY_CLEARED action", async () => {
         const args = [{ type: "history-cleared" }];
         await observer.handlePlacesEvent(args);
         assert.calledWith(dispatch, { type: at.PLACES_HISTORY_CLEARED });
       });
     });
 
+    describe("#page-removed", () => {
+      it("should dispatch a PLACES_LINK_DELETED action with the right url", async () => {
+        const args = [{ type: "page-removed", url: "foo.com" }];
+        await observer.handlePlacesEvent(args);
+        assert.calledWith(dispatch, {
+          type: at.PLACES_LINK_DELETED,
+          data: { url: "foo.com" },
+        });
+      });
+    });
+
     describe("#bookmark-added", () => {
       it("should dispatch a PLACES_BOOKMARK_ADDED action with the bookmark data - http", async () => {
         const args = [
           {
             itemType: TYPE_BOOKMARK,
             source: SOURCES.DEFAULT,
             dateAdded: FAKE_BOOKMARK.dateAdded,
             guid: FAKE_BOOKMARK.bookmarkGuid,
--- a/browser/components/places/tests/browser/browser_library_commands.js
+++ b/browser/components/places/tests/browser/browser_library_commands.js
@@ -76,23 +76,27 @@ add_task(async function test_date_contai
     "Cut command is disabled"
   );
   Assert.ok(
     PO._places.controller.isCommandEnabled("cmd_delete"),
     "Delete command is enabled"
   );
 
   // Execute the delete command and check visit has been removed.
-  let promiseURIRemoved = PlacesTestUtils.waitForNotification(
-    "onDeleteURI",
-    v => TEST_URI.equals(v),
-    "history"
+  const promiseURIRemoved = PlacesTestUtils.waitForNotification(
+    "page-removed",
+    events => events[0].url === TEST_URI.spec,
+    "places"
   );
   PO._places.controller.doCommand("cmd_delete");
-  await promiseURIRemoved;
+  const removeEvents = await promiseURIRemoved;
+  Assert.ok(
+    removeEvents[0].isRemovedFromStore,
+    "isRemovedFromStore should be true"
+  );
 
   // Test live update of "History" query.
   Assert.equal(historyNode.childCount, 0, "History node has no more children");
 
   historyNode.containerOpen = false;
 
   Assert.ok(
     !(await PlacesUtils.history.hasVisits(TEST_URI)),
--- a/browser/components/places/tests/chrome/test_bug549192.xhtml
+++ b/browser/components/places/tests/chrome/test_bug549192.xhtml
@@ -100,20 +100,30 @@
           is(node.uri, places[rc - i - 1].uri.spec,
              "Found expected node at position " + i + ".");
         }
 
         // Now remove the pages and verify live-update again.
         for (let i = 0; i < rc; i++) {
           selection.select(0);
           let node = tree.selectedNode;
-          let promiseDeleted = PlacesTestUtils.waitForNotification("onDeleteURI",
-            uri => uri.spec == node.uri, "history");
+
+          const promiseRemoved = PlacesTestUtils.waitForNotification(
+            "page-removed",
+            events => events[0].url === node.uri,
+            "places"
+          );
+
           tree.controller.remove();
-          await promiseDeleted;
+
+          const removeEvents = await promiseRemoved;
+          ok(
+            removeEvents[0].isRemovedFromStore,
+            "isRemovedFromStore should be true"
+          );
           ok(treeView.treeIndexForNode(node) == -1, node.uri + " removed.");
           is(treeView.rowCount, rc - i - 1, "Rows count decreased");
         }
 
         // Cleanup.
         await PlacesUtils.history.clear();
       })().then(() => SimpleTest.finish());
     }
--- a/browser/components/urlbar/tests/browser/browser_remove_match.js
+++ b/browser/components/urlbar/tests/browser/browser_remove_match.js
@@ -9,35 +9,41 @@ add_task(async function test_remove_hist
   const TEST_URL = "http://remove.me/from_urlbar/";
   await PlacesTestUtils.addVisits(TEST_URL);
 
   registerCleanupFunction(async function() {
     await PlacesUtils.history.clear();
   });
 
   let promiseVisitRemoved = PlacesTestUtils.waitForNotification(
-    "onDeleteURI",
-    uri => uri.spec == TEST_URL,
-    "history"
+    "page-removed",
+    events => events[0].url === TEST_URL,
+    "places"
   );
 
   await UrlbarTestUtils.promiseAutocompleteResultPopup({
     window,
     value: "from_urlbar",
   });
 
   let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1);
   Assert.equal(result.url, TEST_URL, "Found the expected result");
 
   let expectedResultCount = UrlbarTestUtils.getResultCount(window) - 1;
 
   EventUtils.synthesizeKey("KEY_ArrowDown");
   Assert.equal(UrlbarTestUtils.getSelectedRowIndex(window), 1);
   EventUtils.synthesizeKey("KEY_Delete", { shiftKey: true });
-  await promiseVisitRemoved;
+
+  const removeEvents = await promiseVisitRemoved;
+  Assert.ok(
+    removeEvents[0].isRemovedFromStore,
+    "isRemovedFromStore should be true"
+  );
+
   await TestUtils.waitForCondition(
     () => UrlbarTestUtils.getResultCount(window) == expectedResultCount,
     "Waiting for the result to disappear"
   );
 
   for (let i = 0; i < expectedResultCount; i++) {
     let details = await UrlbarTestUtils.getDetailsOfResultAt(window, i);
     Assert.notEqual(
--- a/services/sync/modules/engines/history.js
+++ b/services/sync/modules/engines/history.js
@@ -521,39 +521,39 @@ HistoryTracker.prototype = {
 
   onStart() {
     this._log.info("Adding Places observer.");
     PlacesUtils.history.addObserver(this, true);
     this._placesObserver = new PlacesWeakCallbackWrapper(
       this.handlePlacesEvents.bind(this)
     );
     PlacesObservers.addListener(
-      ["page-visited", "history-cleared"],
+      ["page-visited", "history-cleared", "page-removed"],
       this._placesObserver
     );
   },
 
   onStop() {
     this._log.info("Removing Places observer.");
     PlacesUtils.history.removeObserver(this);
     if (this._placesObserver) {
       PlacesObservers.removeListener(
-        ["page-visited", "history-cleared"],
+        ["page-visited", "history-cleared", "page-removed"],
         this._placesObserver
       );
     }
   },
 
   QueryInterface: ChromeUtils.generateQI([
     "nsINavHistoryObserver",
     "nsISupportsWeakReference",
   ]),
 
   async onDeleteAffectsGUID(uri, guid, reason, source, increment) {
-    if (this.ignoreAll || reason == Ci.nsINavHistoryObserver.REASON_EXPIRED) {
+    if (this.ignoreAll || reason === PlacesVisitRemoved.REASON_EXPIRED) {
       return;
     }
     this._log.trace(source + ": " + uri.spec + ", reason " + reason);
     const added = await this.addChangedID(guid);
     if (added) {
       this.score += increment;
     }
   },
@@ -610,16 +610,32 @@ HistoryTracker.prototype = {
         case "history-cleared": {
           this._log.trace("history-cleared");
           // Note that we're going to trigger a sync, but none of the cleared
           // pages are tracked, so the deletions will not be propagated.
           // See Bug 578694.
           this.score += SCORE_INCREMENT_XLARGE;
           break;
         }
+        case "page-removed": {
+          if (event.reason === PlacesVisitRemoved.REASON_EXPIRED) {
+            return;
+          }
+
+          this._log.trace(
+            "page-removed: " + event.url + ", reason " + event.reason
+          );
+          const added = await this.addChangedID(event.pageGuid);
+          if (added) {
+            this.score += event.isRemovedFromStore
+              ? SCORE_INCREMENT_XLARGE
+              : SCORE_INCREMENT_SMALL;
+          }
+          break;
+        }
       }
     }
   },
 
   onBeginUpdateBatch() {},
   onEndUpdateBatch() {},
   onBeforeDeleteURI() {},
 };
--- a/services/sync/tests/unit/head_helpers.js
+++ b/services/sync/tests/unit/head_helpers.js
@@ -614,37 +614,45 @@ async function serverForFoo(engine, call
 // before the tracker receives the notification. This helper registers an
 // observer that resolves once the expected notification fires.
 async function promiseVisit(expectedType, expectedURI) {
   return new Promise(resolve => {
     function done(type, uri) {
       if (uri == expectedURI.spec && type == expectedType) {
         PlacesUtils.history.removeObserver(observer);
         PlacesObservers.removeListener(
-          ["page-visited"],
+          ["page-visited", "page-removed"],
           observer.handlePlacesEvents
         );
         resolve();
       }
     }
     let observer = {
       handlePlacesEvents(events) {
         Assert.equal(events.length, 1);
-        Assert.equal(events[0].type, "page-visited");
-        done("added", events[0].url);
+
+        if (events[0].type === "page-visited") {
+          done("added", events[0].url);
+        } else if (events[0].type === "page-removed") {
+          Assert.ok(events[0].isRemovedFromStore);
+          done("removed", events[0].url);
+        }
       },
       onBeginUpdateBatch() {},
       onEndUpdateBatch() {},
       onDeleteURI(uri) {
         done("removed", uri.spec);
       },
       onDeleteVisits() {},
     };
     PlacesUtils.history.addObserver(observer, false);
-    PlacesObservers.addListener(["page-visited"], observer.handlePlacesEvents);
+    PlacesObservers.addListener(
+      ["page-visited", "page-removed"],
+      observer.handlePlacesEvents
+    );
   });
 }
 
 async function addVisit(
   suffix,
   referrer = null,
   transition = PlacesUtils.history.TRANSITION_LINK
 ) {
--- a/toolkit/components/downloads/DownloadHistory.jsm
+++ b/toolkit/components/downloads/DownloadHistory.jsm
@@ -191,17 +191,20 @@ var DownloadCache = {
       return this._initializePromise;
     }
     this._initializePromise = (async () => {
       PlacesUtils.history.addObserver(this, true);
 
       const placesObserver = new PlacesWeakCallbackWrapper(
         this.handlePlacesEvents.bind(this)
       );
-      PlacesObservers.addListener(["history-cleared"], placesObserver);
+      PlacesObservers.addListener(
+        ["history-cleared", "page-removed"],
+        placesObserver
+      );
 
       let pageAnnos = await PlacesUtils.history.fetchAnnotatedPages([
         METADATA_ANNO,
         DESTINATIONFILEURI_ANNO,
       ]);
 
       let metaDataPages = pageAnnos.get(METADATA_ANNO);
       if (metaDataPages) {
@@ -330,16 +333,22 @@ var DownloadCache = {
 
   handlePlacesEvents(events) {
     for (const event of events) {
       switch (event.type) {
         case "history-cleared": {
           this._data.clear();
           break;
         }
+        case "page-removed": {
+          if (event.isRemovedFromStore) {
+            this._data.delete(event.url);
+          }
+          break;
+        }
       }
     }
   },
 
   // nsINavHistoryObserver
   onDeleteURI(uri) {
     this._data.delete(uri.spec);
   },
--- a/toolkit/components/downloads/DownloadIntegration.jsm
+++ b/toolkit/components/downloads/DownloadIntegration.jsm
@@ -1228,17 +1228,20 @@ var DownloadObserver = {
  */
 var DownloadHistoryObserver = function(aList) {
   this._list = aList;
   PlacesUtils.history.addObserver(this);
 
   const placesObserver = new PlacesWeakCallbackWrapper(
     this.handlePlacesEvents.bind(this)
   );
-  PlacesObservers.addListener(["history-cleared"], placesObserver);
+  PlacesObservers.addListener(
+    ["history-cleared", "page-removed"],
+    placesObserver
+  );
 };
 
 DownloadHistoryObserver.prototype = {
   /**
    * DownloadList object linked to this observer.
    */
   _list: null,
 
@@ -1246,16 +1249,24 @@ DownloadHistoryObserver.prototype = {
 
   handlePlacesEvents(events) {
     for (const event of events) {
       switch (event.type) {
         case "history-cleared": {
           this._list.removeFinished();
           break;
         }
+        case "page-removed": {
+          if (event.isRemovedFromStore) {
+            this._list.removeFinished(
+              download => event.url === download.source.url
+            );
+          }
+          break;
+        }
       }
     }
   },
 
   // nsINavHistoryObserver
   onDeleteURI: function DL_onDeleteURI(aURI, aGUID) {
     this._list.removeFinished(download =>
       aURI.equals(NetUtil.newURI(download.source.url))
--- a/toolkit/components/places/History.jsm
+++ b/toolkit/components/places/History.jsm
@@ -1026,87 +1026,74 @@ function removeOrphanIcons(db) {
  *      Each object should have the following properties:
  *          - id: (number) The `moz_places` identifier for the place.
  *          - hasVisits: (boolean) If `true`, there remains at least one
  *              visit to this page, so the page should be kept and its
  *              frecency updated.
  *          - hasForeign: (boolean) If `true`, the page has at least
  *              one foreign reference (i.e. a bookmark), so the page should
  *              be kept and its frecency updated.
- * @param transition: (Number)
+ * @param transitionType: (Number)
  *      Set to a valid TRANSITIONS value to indicate all transitions of a
- *      certain type have been removed, otherwise defaults to -1 (unknown value).
+ *      certain type have been removed, otherwise defaults to 0 (unknown value).
  * @return (Promise)
  */
-var notifyCleanup = async function(db, pages, transition = -1) {
+var notifyCleanup = async function(db, pages, transitionType = 0) {
+  const notifications = [];
   let notifiedCount = 0;
-  let observers = PlacesUtils.history.getObservers();
   let bookmarkObservers = PlacesUtils.bookmarks.getObservers();
 
-  let reason = Ci.nsINavHistoryObserver.REASON_DELETED;
-
   for (let page of pages) {
-    let uri = Services.io.newURI(page.url.href);
-    let guid = page.guid;
-    if (page.hasVisits || page.hasForeign) {
-      // We have removed all visits, but the page is still alive, e.g.
-      // because of a bookmark.
-      notify(observers, "onDeleteVisits", [
-        uri,
-        page.hasVisits > 0,
-        guid,
-        reason,
-        transition,
-      ]);
-      // Also asynchronously notify bookmarks for this uri if all the visits
-      // have been removed.
-      if (!page.hasVisits) {
-        PlacesUtils.bookmarks
-          .fetch({ url: page.url }, async bookmark => {
-            let itemId = await PlacesUtils.promiseItemId(bookmark.guid);
-            let parentId = await PlacesUtils.promiseItemId(bookmark.parentGuid);
-            notify(
-              bookmarkObservers,
-              "onItemChanged",
-              [
-                itemId,
-                "cleartime",
-                false,
-                "",
-                0,
-                PlacesUtils.bookmarks.TYPE_BOOKMARK,
-                parentId,
-                bookmark.guid,
-                bookmark.parentGuid,
-                "",
-                PlacesUtils.bookmarks.SOURCES.DEFAULT,
-              ],
-              { concurrent: true }
-            );
-          })
-          .catch(Cu.reportError);
-      }
-    } else {
-      // The page has been entirely removed.
-      notify(observers, "onDeleteURI", [uri, guid, reason]);
-      PlacesObservers.notifyListeners([
-        new PlacesVisitRemoved({
-          url: uri.spec,
-          pageGuid: guid,
-          reason: PlacesVisitRemoved.REASON_DELETED,
-          isRemovedFromStore: true,
-        }),
-      ]);
-    }
-    if (++notifiedCount % NOTIFICATION_CHUNK_SIZE == 0) {
-      // Every few notifications, yield time back to the main
-      // thread to avoid jank.
-      await Promise.resolve();
+    const isRemovedFromStore = !page.hasVisits && !page.hasForeign;
+    notifications.push(
+      new PlacesVisitRemoved({
+        url: Services.io.newURI(page.url.href).spec,
+        pageGuid: page.guid,
+        reason: PlacesVisitRemoved.REASON_DELETED,
+        transitionType,
+        isRemovedFromStore,
+        isPartialVisistsRemoval: !isRemovedFromStore && page.hasVisits > 0,
+      })
+    );
+
+    if (page.hasForeign && !page.hasVisits) {
+      PlacesUtils.bookmarks
+        .fetch({ url: page.url }, async bookmark => {
+          let itemId = await PlacesUtils.promiseItemId(bookmark.guid);
+          let parentId = await PlacesUtils.promiseItemId(bookmark.parentGuid);
+          notify(
+            bookmarkObservers,
+            "onItemChanged",
+            [
+              itemId,
+              "cleartime",
+              false,
+              "",
+              0,
+              PlacesUtils.bookmarks.TYPE_BOOKMARK,
+              parentId,
+              bookmark.guid,
+              bookmark.parentGuid,
+              "",
+              PlacesUtils.bookmarks.SOURCES.DEFAULT,
+            ],
+            { concurrent: true }
+          );
+
+          if (++notifiedCount % NOTIFICATION_CHUNK_SIZE == 0) {
+            // Every few notifications, yield time back to the main
+            // thread to avoid jank.
+            await Promise.resolve();
+          }
+        })
+        .catch(Cu.reportError);
     }
   }
+
+  PlacesObservers.notifyListeners(notifications);
 };
 
 /**
  * Notify an `onResult` callback of a set of operations
  * that just took place.
  *
  * @param data: (Array)
  *      The data to send to the callback.
--- a/toolkit/components/places/PlacesExpiration.jsm
+++ b/toolkit/components/places/PlacesExpiration.jsm
@@ -394,34 +394,16 @@ const EXPIRATION_QUERIES = {
       ACTION.TIMED_OVERLIMIT |
       ACTION.SHUTDOWN_DIRTY |
       ACTION.IDLE_DIRTY |
       ACTION.IDLE_DAILY |
       ACTION.DEBUG,
   },
 };
 
-/**
- * Sends a bookmarks notification through the given observers.
- *
- * @param observers
- *        array of nsINavBookmarkObserver objects.
- * @param notification
- *        the notification name.
- * @param args
- *        array of arguments to pass to the notification.
- */
-function notify(observers, notification, args = []) {
-  for (let observer of observers) {
-    try {
-      observer[notification](...args);
-    } catch (ex) {}
-  }
-}
-
 function nsPlacesExpiration() {
   // Allows other components to easily access getPagesLimit.
   this.wrappedJSObject = this;
 
   XPCOMUtils.defineLazyServiceGetter(
     this,
     "_idle",
     "@mozilla.org/widget/useridleservice;1",
@@ -606,50 +588,39 @@ nsPlacesExpiration.prototype = {
 
     let uri = Services.io.newURI(row.getResultByName("url"));
     let guid = row.getResultByName("guid");
     let visitDate = row.getResultByName("visit_date");
     let wholeEntry = row.getResultByName("whole_entry");
     let mostRecentExpiredVisit = row.getResultByName(
       "most_recent_expired_visit"
     );
-    let reason = Ci.nsINavHistoryObserver.REASON_EXPIRED;
-    let observers = PlacesUtils.history.getObservers();
 
     if (mostRecentExpiredVisit) {
       let days = parseInt(
         (Date.now() - mostRecentExpiredVisit / 1000) / MSECS_PER_DAY
       );
       if (!this._mostRecentExpiredVisitDays) {
         this._mostRecentExpiredVisitDays = days;
       } else if (days < this._mostRecentExpiredVisitDays) {
         this._mostRecentExpiredVisitDays = days;
       }
     }
 
     // Dispatch expiration notifications to history.
-    if (wholeEntry) {
-      notify(observers, "onDeleteURI", [uri, guid, reason]);
-      PlacesObservers.notifyListeners([
-        new PlacesVisitRemoved({
-          url: uri.spec,
-          pageGuid: guid,
-          reason: PlacesVisitRemoved.REASON_EXPIRED,
-          isRemovedFromStore: true,
-        }),
-      ]);
-    } else {
-      notify(observers, "onDeleteVisits", [
-        uri,
-        visitDate > 0,
-        guid,
-        reason,
-        0,
-      ]);
-    }
+    const isRemovedFromStore = !!wholeEntry;
+    PlacesObservers.notifyListeners([
+      new PlacesVisitRemoved({
+        url: uri.spec,
+        pageGuid: guid,
+        reason: PlacesVisitRemoved.REASON_EXPIRED,
+        isRemovedFromStore,
+        isPartialVisistsRemoval: !isRemovedFromStore && visitDate > 0,
+      }),
+    ]);
   },
 
   _shuttingDown: false,
 
   _status: STATUS.UNKNOWN,
   set status(aNewStatus) {
     if (aNewStatus != this._status) {
       // If status changes we should restart the timer.
--- a/toolkit/components/places/nsNavHistoryResult.cpp
+++ b/toolkit/components/places/nsNavHistoryResult.cpp
@@ -14,16 +14,17 @@
 #include "nsNetUtil.h"
 #include "nsString.h"
 #include "nsReadableUtils.h"
 #include "nsUnicharUtils.h"
 #include "prtime.h"
 #include "nsQueryObject.h"
 #include "mozilla/dom/PlacesObservers.h"
 #include "mozilla/dom/PlacesVisit.h"
+#include "mozilla/dom/PlacesVisitRemoved.h"
 #include "mozilla/dom/PlacesVisitTitle.h"
 
 #include "nsCycleCollectionParticipant.h"
 
 // Thanks, Windows.h :(
 #undef CompareString
 
 #define TO_ICONTAINER(_node) \
@@ -3508,16 +3509,17 @@ void nsNavHistoryResult::StopObserving()
   }
   if (mIsHistoryObserver) {
     nsNavHistory* history = nsNavHistory::GetHistoryService();
     if (history) {
       history->RemoveObserver(this);
       mIsHistoryObserver = false;
     }
     events.AppendElement(PlacesEventType::History_cleared);
+    events.AppendElement(PlacesEventType::Page_removed);
   }
   if (mIsHistoryDetailsObserver) {
     events.AppendElement(PlacesEventType::Page_visited);
     events.AppendElement(PlacesEventType::Page_title_changed);
     mIsHistoryDetailsObserver = false;
   }
 
   PlacesObservers::RemoveListener(events, this);
@@ -3543,16 +3545,17 @@ void nsNavHistoryResult::AddHistoryObser
   if (!mIsHistoryObserver) {
     nsNavHistory* history = nsNavHistory::GetHistoryService();
     NS_ASSERTION(history, "Can't create history service");
     history->AddObserver(this, true);
     mIsHistoryObserver = true;
 
     AutoTArray<PlacesEventType, 3> events;
     events.AppendElement(PlacesEventType::History_cleared);
+    events.AppendElement(PlacesEventType::Page_removed);
     if (!mIsHistoryDetailsObserver) {
       events.AppendElement(PlacesEventType::Page_visited);
       events.AppendElement(PlacesEventType::Page_title_changed);
       mIsHistoryDetailsObserver = true;
     }
     PlacesObservers::AddListener(events, this);
   }
   // Don't add duplicate observers.  In some case we don't unregister when
@@ -4198,16 +4201,39 @@ void nsNavHistoryResult::HandlePlacesEve
         ENUMERATE_HISTORY_OBSERVERS(
             OnTitleChanged(uri, titleEvent->mTitle, titleEvent->mPageGuid));
         break;
       }
       case PlacesEventType::History_cleared: {
         ENUMERATE_HISTORY_OBSERVERS(OnClearHistory());
         break;
       }
+      case PlacesEventType::Page_removed: {
+        const PlacesVisitRemoved* removeEvent = event->AsPlacesVisitRemoved();
+        if (NS_WARN_IF(!removeEvent)) {
+          continue;
+        }
+
+        nsCOMPtr<nsIURI> uri;
+        MOZ_ALWAYS_SUCCEEDS(NS_NewURI(getter_AddRefs(uri), removeEvent->mUrl));
+        if (!uri) {
+          continue;
+        }
+
+        if (removeEvent->mIsRemovedFromStore) {
+          ENUMERATE_HISTORY_OBSERVERS(
+              OnDeleteURI(uri, removeEvent->mPageGuid, removeEvent->mReason));
+        } else {
+          ENUMERATE_HISTORY_OBSERVERS(
+              OnDeleteVisits(uri, removeEvent->mIsPartialVisistsRemoval,
+                             removeEvent->mPageGuid, removeEvent->mReason,
+                             removeEvent->mTransitionType));
+        }
+        break;
+      }
       default: {
         MOZ_ASSERT_UNREACHABLE(
             "Receive notification of a type not subscribed to.");
       }
     }
   }
 }
 
rename from toolkit/components/places/tests/expiration/test_notifications_onDeleteVisits.js
rename to toolkit/components/places/tests/expiration/test_notifications_pageRemoved_allVisits.js
--- a/toolkit/components/places/tests/expiration/test_notifications_onDeleteVisits.js
+++ b/toolkit/components/places/tests/expiration/test_notifications_pageRemoved_allVisits.js
@@ -3,23 +3,19 @@
  * 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/. */
 
 /**
  * What this is aimed to test:
  *
  * Expiring only visits for a page, but not the full page, should fire an
- * onDeleteVisits notification.
+ * page-removed for all visits notification.
  */
 
-var hs = Cc["@mozilla.org/browser/nav-history-service;1"].getService(
-  Ci.nsINavHistoryService
-);
-
 var tests = [
   {
     desc: "Add 1 bookmarked page.",
     addPages: 1,
     visitsPerPage: 1,
     addBookmarks: 1,
     limitExpiration: -1,
     expectedNotifications: 1, // Will expire visits for 1 page.
@@ -63,17 +59,17 @@ var tests = [
     addBookmarks: 0,
     limitExpiration: 10,
     expectedNotifications: 10, // Will expire 1 visit for each page, but won't
     // expire pages since they still have visits.
     expectedIsPartialRemoval: true,
   },
 ];
 
-add_task(async function test_notifications_onDeleteVisits() {
+add_task(async () => {
   // Set interval to a large value so we don't expire on it.
   setInterval(3600); // 1h
 
   // Expire anything that is expirable.
   setMaxPages(0);
 
   for (let testIndex = 1; testIndex <= tests.length; testIndex++) {
     let currentTest = tests[testIndex - 1];
@@ -106,42 +102,42 @@ add_task(async function test_notificatio
         parentGuid: PlacesUtils.bookmarks.unfiledGuid,
         title: null,
         url: page,
       });
       currentTest.bookmarks.push(page);
     }
 
     // Observe history.
-    let historyObserver = {
-      onBeginUpdateBatch: function PEX_onBeginUpdateBatch() {},
-      onEndUpdateBatch: function PEX_onEndUpdateBatch() {},
-      onDeleteURI(aURI, aGUID, aReason) {
-        // Check this uri was not bookmarked.
-        Assert.equal(currentTest.bookmarks.indexOf(aURI.spec), -1);
-        do_check_valid_places_guid(aGUID);
-        Assert.equal(aReason, Ci.nsINavHistoryObserver.REASON_EXPIRED);
-      },
-      onDeleteVisits(aURI, aPartialRemoval, aGUID, aReason) {
-        currentTest.receivedNotifications++;
-        do_check_guid_for_uri(aURI, aGUID);
-        Assert.equal(
-          aPartialRemoval,
-          currentTest.expectedIsPartialRemoval,
-          "Should have the correct flag setting for partial removal"
-        );
-        Assert.equal(aReason, Ci.nsINavHistoryObserver.REASON_EXPIRED);
-      },
+    const listener = events => {
+      for (const event of events) {
+        Assert.equal(event.type, "page-removed");
+        Assert.equal(event.reason, PlacesVisitRemoved.REASON_EXPIRED);
+
+        if (event.isRemovedFromStore) {
+          // Check this uri was not bookmarked.
+          Assert.equal(currentTest.bookmarks.indexOf(event.url), -1);
+          do_check_valid_places_guid(event.pageGuid);
+        } else {
+          currentTest.receivedNotifications++;
+          do_check_guid_for_uri(Services.io.newURI(event.url), event.pageGuid);
+          Assert.equal(
+            event.isPartialVisistsRemoval,
+            currentTest.expectedIsPartialRemoval,
+            "Should have the correct flag setting for partial removal"
+          );
+        }
+      }
     };
-    hs.addObserver(historyObserver);
+    PlacesObservers.addListener(["page-removed"], listener);
 
     // Expire now.
     await promiseForceExpirationStep(currentTest.limitExpiration);
 
-    hs.removeObserver(historyObserver, false);
+    PlacesObservers.removeListener(["page-removed"], listener);
 
     Assert.equal(
       currentTest.receivedNotifications,
       currentTest.expectedNotifications
     );
 
     // Clean up.
     await PlacesUtils.bookmarks.eraseEverything();
rename from toolkit/components/places/tests/expiration/test_notifications_onDeleteURI.js
rename to toolkit/components/places/tests/expiration/test_notifications_pageRemoved_fromStore.js
--- a/toolkit/components/places/tests/expiration/test_notifications_onDeleteURI.js
+++ b/toolkit/components/places/tests/expiration/test_notifications_pageRemoved_fromStore.js
@@ -2,23 +2,19 @@
  * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ :
  * 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/. */
 
 /**
  * What this is aimed to test:
  *
- * Expiring a full page should fire an onDeleteURI notification.
+ * Expiring a full page should fire an page-removed event notification.
  */
 
-var hs = Cc["@mozilla.org/browser/nav-history-service;1"].getService(
-  Ci.nsINavHistoryService
-);
-
 var tests = [
   {
     desc: "Add 1 bookmarked page.",
     addPages: 1,
     addBookmarks: 1,
     expectedNotifications: 0, // No expirable pages.
   },
 
@@ -39,17 +35,17 @@ var tests = [
   {
     desc: "Add 10 pages, all bookmarked.",
     addPages: 10,
     addBookmarks: 10,
     expectedNotifications: 0, // No expirable pages.
   },
 ];
 
-add_task(async function test_notifications_onDeleteURI() {
+add_task(async () => {
   // Set interval to a large value so we don't expire on it.
   setInterval(3600); // 1h
 
   // Expire anything that is expirable.
   setMaxPages(0);
 
   for (let testIndex = 1; testIndex <= tests.length; testIndex++) {
     let currentTest = tests[testIndex - 1];
@@ -71,34 +67,37 @@ add_task(async function test_notificatio
         parentGuid: PlacesUtils.bookmarks.unfiledGuid,
         title: null,
         url: page,
       });
       currentTest.bookmarks.push(page);
     }
 
     // Observe history.
-    let historyObserver = {
-      onBeginUpdateBatch: function PEX_onBeginUpdateBatch() {},
-      onEndUpdateBatch: function PEX_onEndUpdateBatch() {},
-      onDeleteURI(aURI, aGUID, aReason) {
+    const listener = events => {
+      for (const event of events) {
+        Assert.equal(event.type, "page-removed");
+
+        if (!event.isRemovedFromStore) {
+          continue;
+        }
+
         currentTest.receivedNotifications++;
         // Check this uri was not bookmarked.
-        Assert.equal(currentTest.bookmarks.indexOf(aURI.spec), -1);
-        do_check_valid_places_guid(aGUID);
-        Assert.equal(aReason, Ci.nsINavHistoryObserver.REASON_EXPIRED);
-      },
-      onDeleteVisits() {},
+        Assert.equal(currentTest.bookmarks.indexOf(event.url), -1);
+        do_check_valid_places_guid(event.pageGuid);
+        Assert.equal(event.reason, PlacesVisitRemoved.REASON_EXPIRED);
+      }
     };
-    hs.addObserver(historyObserver);
+    PlacesObservers.addListener(["page-removed"], listener);
 
     // Expire now.
     await promiseForceExpirationStep(-1);
 
-    hs.removeObserver(historyObserver, false);
+    PlacesObservers.removeListener(["page-removed"], listener);
 
     Assert.equal(
       currentTest.receivedNotifications,
       currentTest.expectedNotifications
     );
 
     // Clean up.
     await PlacesUtils.bookmarks.eraseEverything();
--- a/toolkit/components/places/tests/expiration/test_pref_maxpages.js
+++ b/toolkit/components/places/tests/expiration/test_pref_maxpages.js
@@ -90,22 +90,34 @@ add_task(async function test_pref_maxpag
       print("onDeleteURI " + aURI.spec);
       currentTest.receivedNotifications++;
     };
     historyObserver.onDeleteVisits = (aURI, aPartialRemoval) => {
       print("onDeleteVisits " + aURI.spec + " " + aPartialRemoval);
     };
     PlacesUtils.history.addObserver(historyObserver);
 
+    const listener = events => {
+      for (const event of events) {
+        print("page-removed " + event.url);
+        Assert.equal(event.type, "page-removed");
+        Assert.ok(event.isRemovedFromStore);
+        Assert.equal(event.reason, PlacesVisitRemoved.REASON_EXPIRED);
+        currentTest.receivedNotifications++;
+      }
+    };
+    PlacesObservers.addListener(["page-removed"], listener);
+
     setMaxPages(currentTest.maxPages);
 
     // Expire now.
     await promiseForceExpirationStep(-1);
 
     PlacesUtils.history.removeObserver(historyObserver, false);
+    PlacesObservers.removeListener(["page-removed"], listener);
 
     Assert.equal(
       currentTest.receivedNotifications,
       currentTest.expectedNotifications
     );
 
     // Clean up.
     await PlacesUtils.history.clear();
--- a/toolkit/components/places/tests/expiration/xpcshell.ini
+++ b/toolkit/components/places/tests/expiration/xpcshell.ini
@@ -2,12 +2,12 @@
 head = head_expiration.js
 skip-if = toolkit == 'android'
 
 [test_annos_expire_never.js]
 [test_clearHistory.js]
 [test_debug_expiration.js]
 [test_idle_daily.js]
 [test_notifications.js]
-[test_notifications_onDeleteURI.js]
-[test_notifications_onDeleteVisits.js]
+[test_notifications_pageRemoved_allVisits.js]
+[test_notifications_pageRemoved_fromStore.js]
 [test_pref_interval.js]
 [test_pref_maxpages.js]
--- a/toolkit/components/places/tests/history/test_remove.js
+++ b/toolkit/components/places/tests/history/test_remove.js
@@ -82,23 +82,42 @@ add_task(async function test_remove_sing
             case "pages-rank-changed": {
               try {
                 Assert.ok(!shouldRemove, "Observing pages-rank-changed event");
               } finally {
                 resolve();
               }
               break;
             }
+            case "page-removed": {
+              Assert.equal(
+                event.isRemovedFromStore,
+                shouldRemove,
+                "Observe page-removed event with right removal type"
+              );
+              Assert.equal(
+                event.url,
+                uri.spec,
+                "Observing effect on the right uri"
+              );
+              resolve();
+              break;
+            }
           }
         }
       };
     });
     PlacesUtils.history.addObserver(observer);
     PlacesObservers.addListener(
-      ["page-title-changed", "history-cleared", "pages-rank-changed"],
+      [
+        "page-title-changed",
+        "history-cleared",
+        "pages-rank-changed",
+        "page-removed",
+      ],
       placesEventListener
     );
 
     info("Performing removal");
     let removed = false;
     if (options.useCallback) {
       let onRowCalled = false;
       let guid = do_get_guid_for_uri(uri);
@@ -116,17 +135,22 @@ add_task(async function test_remove_sing
       Assert.ok(onRowCalled, "Callback has been called");
     } else {
       removed = await PlacesUtils.history.remove(removeArg);
     }
 
     await promiseObserved;
     PlacesUtils.history.removeObserver(observer);
     PlacesObservers.removeListener(
-      ["page-title-changed", "history-cleared", "pages-rank-changed"],
+      [
+        "page-title-changed",
+        "history-cleared",
+        "pages-rank-changed",
+        "page-removed",
+      ],
       placesEventListener
     );
 
     Assert.equal(visits_in_database(uri), 0, "History entry has disappeared");
     Assert.notEqual(
       visits_in_database(WITNESS_URI),
       0,
       "Witness URI still has visits"
--- a/toolkit/components/places/tests/history/test_removeByFilter.js
+++ b/toolkit/components/places/tests/history/test_removeByFilter.js
@@ -61,17 +61,17 @@ add_task(async function test_removeByFil
     let { observer, placesEventListener, promiseObserved } = getObserverPromise(
       bookmarkedUri
     );
     if (observer) {
       PlacesUtils.history.addObserver(observer, false);
     }
     if (placesEventListener) {
       PlacesObservers.addListener(
-        ["page-title-changed", "history-cleared"],
+        ["page-title-changed", "history-cleared", "page-removed"],
         placesEventListener
       );
     }
     // Perfom delete operation on database
     let removed = false;
     if (useCallback) {
       // The amount of callbacks will be the unique URIs to remove from the database
       let netCallbacksRequired = new Set(visits.map(v => v.uri)).size;
@@ -93,17 +93,17 @@ add_task(async function test_removeByFil
     await promiseObserved;
     if (observer) {
       PlacesUtils.history.removeObserver(observer);
       // Remove the added bookmarks as they interfere with following tests
       await PlacesUtils.bookmarks.eraseEverything();
     }
     if (placesEventListener) {
       PlacesObservers.removeListener(
-        ["page-title-changed", "history-cleared"],
+        ["page-title-changed", "history-cleared", "page-removed"],
         placesEventListener
       );
     }
     Assert.ok(
       await PlacesTestUtils.isPageInDB(witnessURI),
       "Witness URI is still in database"
     );
     return removed;
@@ -499,14 +499,36 @@ function getObserverPromise(bookmarkedUr
           case "page-title-changed": {
             reject(new Error("Unexpected page-title-changed event happens"));
             break;
           }
           case "history-cleared": {
             reject(new Error("Unexpected history-cleared event happens"));
             break;
           }
+          case "page-removed": {
+            if (event.isRemovedFromStore) {
+              Assert.notEqual(
+                event.url,
+                bookmarkedUri,
+                "Bookmarked URI should not be deleted"
+              );
+            } else {
+              Assert.equal(
+                event.isPartialVisistsRemoval,
+                false,
+                "Observing page-removed deletes all visits"
+              );
+              Assert.equal(
+                event.url,
+                bookmarkedUri,
+                "Bookmarked URI should have all visits removed but not the page itself"
+              );
+            }
+            resolve();
+            break;
+          }
         }
       }
     };
   });
   return { observer, placesEventListener, promiseObserved };
 }
--- a/toolkit/components/places/tests/history/test_removeMany.js
+++ b/toolkit/components/places/tests/history/test_removeMany.js
@@ -37,16 +37,20 @@ add_task(async function test_remove_many
       title,
       hasBookmark,
       // `true` once `onResult` has been called for this page
       onResultCalled: false,
       // `true` once `onDeleteVisits` has been called for this page
       onDeleteVisitsCalled: false,
       // `true` once `onDeleteURI` has been called for this page
       onDeleteURICalled: false,
+      // `true` once page-removed for store has been fired for this page
+      pageRemovedFromStore: false,
+      // `true` once page-removed for all visits has been fired for this page
+      pageRemovedAllVisits: false,
     };
     info("Pushing: " + uri.spec);
     pages.push(page);
 
     await PlacesTestUtils.addVisits(page);
     page.guid = do_get_guid_for_uri(uri);
     if (hasBookmark) {
       await PlacesUtils.bookmarks.insert({
@@ -121,22 +125,50 @@ add_task(async function test_remove_many
         case "history-cleared": {
           Assert.ok(false, "Unexpected history-cleared event happens");
           break;
         }
         case "pages-rank-changed": {
           onPageRankingChanged = true;
           break;
         }
+        case "page-removed": {
+          const origin = pages.find(x => x.uri.spec === event.url);
+          Assert.ok(origin);
+
+          if (event.isRemovedFromStore) {
+            Assert.ok(
+              !origin.hasBookmark,
+              "Observing page-removed event on a page without a bookmark"
+            );
+            Assert.ok(
+              !origin.pageRemovedFromStore,
+              "Observing page-removed for store for the first time"
+            );
+            origin.pageRemovedFromStore = true;
+          } else {
+            Assert.ok(
+              !origin.pageRemovedAllVisits,
+              "Observing page-removed for all visits for the first time"
+            );
+            origin.pageRemovedAllVisits = true;
+          }
+          break;
+        }
       }
     }
   };
 
   PlacesObservers.addListener(
-    ["page-title-changed", "history-cleared", "pages-rank-changed"],
+    [
+      "page-title-changed",
+      "history-cleared",
+      "pages-rank-changed",
+      "page-removed",
+    ],
     placesEventListener
   );
 
   info("Removing the pages and checking the callbacks");
 
   let removed = await PlacesUtils.history.remove(keys, page => {
     let origin = pages.find(candidate => candidate.uri.spec == page.url.href);
 
@@ -146,17 +178,22 @@ add_task(async function test_remove_many
     Assert.equal(page.guid, origin.guid, "onResult has the right guid");
     Assert.equal(page.title, origin.title, "onResult has the right title");
   });
 
   Assert.ok(removed, "Something was removed");
 
   PlacesUtils.history.removeObserver(observer);
   PlacesObservers.removeListener(
-    ["page-title-changed", "history-cleared", "pages-rank-changed"],
+    [
+      "page-title-changed",
+      "history-cleared",
+      "pages-rank-changed",
+      "page-removed",
+    ],
     placesEventListener
   );
 
   info("Checking out results");
   // By now the observers should have been called.
   for (let i = 0; i < pages.length; ++i) {
     let page = pages[i];
     Assert.ok(
@@ -168,27 +205,26 @@ add_task(async function test_remove_many
       "History entry has disappeared"
     );
     Assert.equal(
       page_in_database(page.uri) != 0,
       page.hasBookmark,
       "Page is present only if it also has bookmarks"
     );
     Assert.notEqual(
-      page.onDeleteURICalled,
-      page.onDeleteVisitsCalled,
-      "Either only onDeleteVisits or onDeleteVisitsCalled should be called"
+      page.pageRemovedFromStore,
+      page.pageRemovedAllVisits,
+      "Either only page-removed event for store or all visits should be called"
     );
   }
 
   Assert.equal(
     onPageRankingChanged,
-    pages.some(p => p.onDeleteVisitsCalled) ||
-      pages.some(p => p.onDeleteURICalled),
-    "page-rank-changed was fired if onDeleteVisitsCalled or onDeleteURICalled was called"
+    pages.some(p => p.pageRemovedFromStore || p.pageRemovedAllVisits),
+    "page-rank-changed was fired if page-removed was fired"
   );
 
   Assert.notEqual(
     visits_in_database(WITNESS_URI),
     0,
     "Witness URI still has visits"
   );
   Assert.notEqual(
--- a/toolkit/components/places/tests/unit/test_history_observer.js
+++ b/toolkit/components/places/tests/unit/test_history_observer.js
@@ -144,53 +144,64 @@ add_task(async function test_multiple_on
   await PlacesTestUtils.addVisits([
     { uri: testuri, transition: TRANSITION_LINK },
     { uri: testuri, referrer: testuri, transition: TRANSITION_LINK },
     { uri: testuri, transition: TRANSITION_TYPED },
   ]);
   await promiseNotifications;
 });
 
-add_task(async function test_onDeleteURI() {
-  let promiseNotify = onNotify(function onDeleteURI(aURI, aGUID, aReason) {
-    Assert.ok(aURI.equals(testuri));
-    // Can't use do_check_guid_for_uri() here because the visit is already gone.
-    Assert.equal(aGUID, testguid);
-    Assert.equal(aReason, Ci.nsINavHistoryObserver.REASON_DELETED);
-  });
+add_task(async function test_pageRemovedFromStore() {
   let [testuri] = await task_add_visit();
   let testguid = do_get_guid_for_uri(testuri);
+
+  const promiseNotify = PlacesTestUtils.waitForNotification(
+    "page-removed",
+    () => true,
+    "places"
+  );
+
   await PlacesUtils.history.remove(testuri);
-  await promiseNotify;
+
+  const events = await promiseNotify;
+  Assert.equal(events.length, 1, "Right number of page-removed notified");
+  Assert.equal(events[0].type, "page-removed");
+  Assert.ok(events[0].isRemovedFromStore);
+  Assert.equal(events[0].url, testuri.spec);
+  Assert.equal(events[0].pageGuid, testguid);
+  Assert.equal(events[0].reason, PlacesVisitRemoved.REASON_DELETED);
 });
 
-add_task(async function test_onDeleteVisits() {
-  let promiseNotify = onNotify(function onDeleteVisits(
-    aURI,
-    aVisitTime,
-    aGUID,
-    aReason
-  ) {
-    Assert.ok(aURI.equals(testuri));
-    // Can't use do_check_guid_for_uri() here because the visit is already gone.
-    Assert.equal(aGUID, testguid);
-    Assert.equal(aReason, Ci.nsINavHistoryObserver.REASON_DELETED);
-    Assert.equal(aVisitTime, 0); // All visits have been removed.
-  });
+add_task(async function test_pageRemovedAllVisits() {
+  const promiseNotify = PlacesTestUtils.waitForNotification(
+    "page-removed",
+    () => true,
+    "places"
+  );
+
   let msecs24hrsAgo = Date.now() - 86400 * 1000;
   let [testuri] = await task_add_visit(undefined, msecs24hrsAgo * 1000);
   // Add a bookmark so the page is not removed.
   await PlacesUtils.bookmarks.insert({
     parentGuid: PlacesUtils.bookmarks.unfiledGuid,
     title: "test",
     url: testuri,
   });
   let testguid = do_get_guid_for_uri(testuri);
   await PlacesUtils.history.remove(testuri);
-  await promiseNotify;
+
+  const events = await promiseNotify;
+  Assert.equal(events.length, 1, "Right number of page-removed notified");
+  Assert.equal(events[0].type, "page-removed");
+  Assert.ok(!events[0].isRemovedFromStore);
+  Assert.equal(events[0].url, testuri.spec);
+  // Can't use do_check_guid_for_uri() here because the visit is already gone.
+  Assert.equal(events[0].pageGuid, testguid);
+  Assert.equal(events[0].reason, PlacesVisitRemoved.REASON_DELETED);
+  Assert.ok(!events[0].isPartialVisistsRemoval); // All visits have been removed.
 });
 
 add_task(async function test_pageTitleChanged() {
   const [testuri] = await task_add_visit();
   const title = "test-title";
 
   const promiseNotify = PlacesTestUtils.waitForNotification(
     "page-title-changed",
--- a/toolkit/components/thumbnails/PageThumbs.jsm
+++ b/toolkit/components/thumbnails/PageThumbs.jsm
@@ -117,31 +117,40 @@ var PageThumbs = {
   init: function PageThumbs_init() {
     if (!this._initialized) {
       this._initialized = true;
       PlacesUtils.history.addObserver(PageThumbsHistoryObserver, true);
 
       this._placesObserver = new PlacesWeakCallbackWrapper(
         this.handlePlacesEvents.bind(this)
       );
-      PlacesObservers.addListener(["history-cleared"], this._placesObserver);
+      PlacesObservers.addListener(
+        ["history-cleared", "page-removed"],
+        this._placesObserver
+      );
 
       // Migrate the underlying storage, if needed.
       PageThumbsStorageMigrator.migrate();
       PageThumbsExpiration.init();
     }
   },
 
   handlePlacesEvents(events) {
     for (const event of events) {
       switch (event.type) {
         case "history-cleared": {
           PageThumbsStorage.wipe();
           break;
         }
+        case "page-removed": {
+          if (event.isRemovedFromStore) {
+            PageThumbsStorage.remove(event.url);
+          }
+          break;
+        }
       }
     }
   },
 
   uninit: function PageThumbs_uninit() {
     if (this._initialized) {
       this._initialized = false;
     }
--- a/toolkit/modules/NewTabUtils.jsm
+++ b/toolkit/modules/NewTabUtils.jsm
@@ -606,16 +606,17 @@ var PlacesProvider = {
       this.handlePlacesEvents.bind(this)
     );
     PlacesObservers.addListener(
       [
         "page-visited",
         "page-title-changed",
         "history-cleared",
         "pages-rank-changed",
+        "page-removed",
       ],
       this._placesObserver
     );
   },
 
   /**
    * Gets the current set of links delivered by this provider.
    * @param aCallback The function that the array of links is passed to.
@@ -743,16 +744,22 @@ var PlacesProvider = {
         case "history-cleared": {
           this.onClearHistory();
           break;
         }
         case "pages-rank-changed": {
           this.onManyFrecenciesChanged();
           break;
         }
+        case "page-removed": {
+          if (event.isRemovedFromStore) {
+            this.onDeleteURI(event.url, event.pageGuid, event.reason);
+          }
+          break;
+        }
       }
     }
   },
 
   onDeleteURI: function PlacesProvider_onDeleteURI(aURI, aGUID, aReason) {
     // let observers remove sensetive data associated with deleted visit
     this._callObservers("onDeleteURI", {
       url: aURI.spec,