Bug 1583413 - Fetch the Send Tab target list from FxA, not Sync. r=markh,eoger
authorLina Cambridge <lina@yakshaving.ninja>
Thu, 03 Oct 2019 22:40:55 +0000
changeset 496254 0e871ed50b6c30999fba87362058c60112fc8195
parent 496253 8d090eb60c78fd6c28d7f5d8832c8bb498aafbd4
child 496255 ecb0e3d35a47ef3e11938d256522e187aee57450
push id36647
push usernerli@mozilla.com
push dateFri, 04 Oct 2019 04:09:18 +0000
treeherdermozilla-central@678d4d2c3c4d [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmarkh, eoger
bugs1583413
milestone71.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 1583413 - Fetch the Send Tab target list from FxA, not Sync. r=markh,eoger Instead of using the list of FxA devices from the Sync clients engine, we now fetch the list of Send Tab devices from FxA. This works like this: * `FxAccountsDevice#getDeviceList` has been split up into `recentDeviceList` and `refreshDeviceList`. * `recentDeviceList` synchronously returns the last fetched list, so that consumers like Send Tab can use it right away. * `refreshDeviceList` is asynchronous, and refreshes the last fetched list. Refreshes are limited to once every minute by default, matching the minimum sync interval (Send Tab passes the `ignoreCached` option to override the limit if the user clicks the "refresh" button). Concurrent calls to `refreshDeviceList` are also serialized, to ensure the list is only fetched once. * The list is flagged as stale when a device is connected or disconnected. It's still kept around, but the next call to `refreshDeviceList` will fetch a new list from the server. * The Send Tab UI refreshes FxA devices in the background. Matching FxA devices to Sync client records is best effort; we don't do it if Sync isn't configured or hasn't run yet. This only impacts the fallback case if the target doesn't support FxA commands. Differential Revision: https://phabricator.services.mozilla.com/D47521
browser/base/content/browser-sync.js
browser/base/content/test/pageActions/browser_page_action_menu.js
browser/base/content/test/sync/browser_contextmenu_sendpage.js
browser/base/content/test/sync/browser_contextmenu_sendtab.js
browser/base/content/test/sync/head.js
browser/components/about/AboutProtectionsHandler.jsm
services/fxaccounts/FxAccounts.jsm
services/fxaccounts/FxAccountsCommands.js
services/fxaccounts/FxAccountsDevice.jsm
services/fxaccounts/tests/xpcshell/test_accounts_device_registration.js
services/sync/modules/engines/clients.js
services/sync/tests/unit/test_clients_engine.js
--- a/browser/base/content/browser-sync.js
+++ b/browser/base/content/browser-sync.js
@@ -52,42 +52,49 @@ var gSync = {
 
   get brandStrings() {
     delete this.brandStrings;
     return (this.brandStrings = Services.strings.createBundle(
       "chrome://branding/locale/brand.properties"
     ));
   },
 
-  get syncReady() {
-    return Cc["@mozilla.org/weave/service;1"].getService().wrappedJSObject
-      .ready;
-  },
-
-  // Returns true if sync is configured but hasn't loaded or the send tab
-  // targets list isn't ready yet.
+  // Returns true if FxA is configured, but the send tab targets list isn't
+  // ready yet.
   get sendTabConfiguredAndLoading() {
     return (
       UIState.get().status == UIState.STATUS_SIGNED_IN &&
-      (!this.syncReady || !Weave.Service.clientsEngine.hasSyncedThisSession)
+      !fxAccounts.device.recentDeviceList
     );
   },
 
   get isSignedIn() {
     return UIState.get().status == UIState.STATUS_SIGNED_IN;
   },
 
-  get sendTabTargets() {
-    return Weave.Service.clientsEngine.fxaDevices
-      .sort((a, b) => a.name.localeCompare(b.name))
-      .filter(
-        d =>
-          !d.isCurrentDevice &&
-          (fxAccounts.commands.sendTab.isDeviceCompatible(d) || d.clientRecord)
+  getSendTabTargets() {
+    let targets = [];
+    if (!fxAccounts.device.recentDeviceList) {
+      return targets;
+    }
+    for (let d of fxAccounts.device.recentDeviceList) {
+      if (d.isCurrentDevice) {
+        continue;
+      }
+      let clientRecord = Weave.Service.clientsEngine.getClientByFxaDeviceId(
+        d.id
       );
+      if (clientRecord || fxAccounts.commands.sendTab.isDeviceCompatible(d)) {
+        targets.push({
+          clientRecord,
+          ...d,
+        });
+      }
+    }
+    return targets.sort((a, b) => a.name.localeCompare(b.name));
   },
 
   _generateNodeGetters() {
     for (let k of ["Status", "Avatar", "Label"]) {
       let prop = "appMenu" + k;
       let suffix = k.toLowerCase();
       delete this[prop];
       this.__defineGetter__(prop, function() {
@@ -215,16 +222,34 @@ var gSync = {
   },
 
   updateAllUI(state) {
     this.updatePanelPopup(state);
     this.updateState(state);
     this.updateSyncButtonsTooltip(state);
     this.updateSyncStatus(state);
     this.updateFxAPanel(state);
+    // Refresh the device list in the background.
+    this.refreshFxaDevices();
+  },
+
+  async refreshFxaDevices(options) {
+    if (UIState.get().status != UIState.STATUS_SIGNED_IN) {
+      console.info("Skipping device list refresh; not signed in");
+      return;
+    }
+    try {
+      // Poke FxA to refresh the recent device list. It's safe to call
+      // `refreshDeviceList` multiple times in the background, as it avoids
+      // making new requests if one is already active, and caches the list for
+      // 1 minute by default.
+      await fxAccounts.device.refreshDeviceList(options);
+    } catch (e) {
+      console.error("Refreshing device list failed.", e);
+    }
   },
 
   updateSendToDeviceTitle() {
     let string = gBrowserBundle.GetStringFromName("sendTabsToDevice.label");
     let title = PluralForm.get(1, string).replace("#1", 1);
     if (gBrowser.selectedTab.multiselected) {
       let tabCount = gBrowser.selectedTabs.length;
       title = PluralForm.get(tabCount, string).replace("#1", tabCount);
@@ -243,17 +268,20 @@ var gSync = {
 
   showSendToDeviceViewFromFxaMenu(anchor) {
     const { status } = UIState.get();
     if (status === UIState.STATUS_NOT_CONFIGURED) {
       PanelUI.showSubView("PanelUI-fxa-menu-sendtab-not-configured", anchor);
       return;
     }
 
-    if (this.sendTabConfiguredAndLoading || this.sendTabTargets.length <= 0) {
+    const targets = this.sendTabConfiguredAndLoading
+      ? []
+      : this.getSendTabTargets();
+    if (!targets.length) {
       PanelUI.showSubView("PanelUI-fxa-menu-sendtab-no-devices", anchor);
       return;
     }
 
     this.showSendToDeviceView(anchor);
     this.emitFxaToolbarTelemetry("send_tab", anchor);
   },
 
@@ -303,30 +331,40 @@ var gSync = {
             PanelMultiView.hidePopup(panelNode);
           }
         });
         return item;
       }
     );
 
     bodyNode.removeAttribute("state");
-    // In the first ~10 sec after startup, Sync may not be loaded and the list
-    // of devices will be empty.
+    // If the app just started, we won't have fetched the device list yet. Sync
+    // does this automatically ~10 sec after startup, but there's no trigger for
+    // this if we're signed in to FxA, but not Sync.
     if (gSync.sendTabConfiguredAndLoading) {
       bodyNode.setAttribute("state", "notready");
     }
-    if (reloadDevices && UIState.get().syncEnabled) {
-      // Force a background Sync
-      Services.tm.dispatchToMainThread(async () => {
-        // `engines: []` = clients engine only + refresh FxA Devices.
-        await Weave.Service.sync({ why: "pageactions", engines: [] });
-        if (!window.closed) {
-          this.populateSendTabToDevicesView(panelViewNode, false);
-        }
-      });
+    if (reloadDevices) {
+      if (UIState.get().syncEnabled) {
+        Services.tm.dispatchToMainThread(async () => {
+          // `engines: []` = clients engine only + refresh FxA Devices.
+          await Weave.Service.sync({ why: "pageactions", engines: [] });
+          if (!window.closed) {
+            this.populateSendTabToDevicesView(panelViewNode, false);
+          }
+        });
+      } else {
+        // Force a refresh, since the user probably connected a new device, and
+        // is waiting for it to show up.
+        this.refreshFxaDevices({ ignoreCached: true }).then(_ => {
+          if (!window.closed) {
+            this.populateSendTabToDevicesView(panelViewNode, false);
+          }
+        });
+      }
     }
   },
 
   toggleAccountPanel(
     viewId,
     anchor = document.getElementById("fxa-toolbar-menu-button"),
     aEvent
   ) {
@@ -801,18 +839,20 @@ var gSync = {
       // We can only be in this case in the page action menu.
       return;
     }
 
     const fragment = document.createDocumentFragment();
 
     const state = UIState.get();
     if (state.status == UIState.STATUS_SIGNED_IN) {
-      if (this.sendTabTargets.length) {
+      const targets = this.getSendTabTargets();
+      if (targets.length) {
         this._appendSendTabDeviceList(
+          targets,
           fragment,
           createDeviceNodeFn,
           url,
           title,
           multiselected
         );
       } else {
         this._appendSendTabSingleDevice(fragment, createDeviceNodeFn);
@@ -824,28 +864,24 @@ var gSync = {
       this._appendSendTabVerify(fragment, createDeviceNodeFn);
     } /* status is STATUS_NOT_CONFIGURED */ else {
       this._appendSendTabUnconfigured(fragment, createDeviceNodeFn);
     }
 
     devicesPopup.appendChild(fragment);
   },
 
-  // TODO: once our transition from the old-send tab world is complete,
-  // this list should be built using the FxA device list instead of the client
-  // collection.
   _appendSendTabDeviceList(
+    targets,
     fragment,
     createDeviceNodeFn,
     url,
     title,
     multiselected
   ) {
-    const targets = this.sendTabTargets;
-
     let tabsToSend = multiselected
       ? gBrowser.selectedTabs.map(t => {
           return {
             url: t.linkedBrowser.currentURI.spec,
             title: t.linkedBrowser.contentTitle,
           };
         })
       : [{ url, title }];
@@ -1336,16 +1372,17 @@ var gSync = {
     }
     const relativeDateStr = this.relativeTimeFormat.formatBestUnit(date);
     return this.syncStrings.formatStringFromName("lastSync2.label", [
       relativeDateStr,
     ]);
   },
 
   onClientsSynced() {
+    // Note that this element is only shown if Sync is enabled.
     let element = document.getElementById("PanelUI-remotetabs-main");
     if (element) {
       if (Weave.Service.clientsEngine.stats.numClients > 1) {
         element.setAttribute("devices-status", "multi");
       } else {
         element.setAttribute("devices-status", "single");
       }
     }
--- a/browser/base/content/test/pageActions/browser_page_action_menu.js
+++ b/browser/base/content/test/pageActions/browser_page_action_menu.js
@@ -302,17 +302,17 @@ add_task(async function sendToDevice_non
   });
 });
 
 add_task(async function sendToDevice_syncNotReady_other_states() {
   // Open a tab that's sendable.
   await BrowserTestUtils.withNewTab("http://example.com/", async () => {
     await promiseSyncReady();
     const sandbox = sinon.createSandbox();
-    sandbox.stub(gSync, "syncReady").get(() => false);
+    sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => null);
     sandbox
       .stub(UIState, "get")
       .returns({ status: UIState.STATUS_NOT_VERIFIED });
     sandbox.stub(gSync, "isSendableURI").returns(true);
 
     let cleanUp = () => {
       sandbox.restore();
     };
@@ -361,27 +361,32 @@ add_task(async function sendToDevice_syn
   });
 });
 
 add_task(async function sendToDevice_syncNotReady_configured() {
   // Open a tab that's sendable.
   await BrowserTestUtils.withNewTab("http://example.com/", async () => {
     await promiseSyncReady();
     const sandbox = sinon.createSandbox();
-    const syncReady = sandbox.stub(gSync, "syncReady").get(() => false);
-    const hasSyncedThisSession = sandbox
-      .stub(Weave.Service.clientsEngine, "hasSyncedThisSession")
-      .get(() => false);
+    const recentDeviceList = sandbox
+      .stub(fxAccounts.device, "recentDeviceList")
+      .get(() => null);
     sandbox.stub(UIState, "get").returns({ status: UIState.STATUS_SIGNED_IN });
     sandbox.stub(gSync, "isSendableURI").returns(true);
 
-    sandbox.stub(Weave.Service, "sync").callsFake(() => {
-      syncReady.get(() => true);
-      hasSyncedThisSession.get(() => true);
-      sandbox.stub(gSync, "sendTabTargets").get(() => mockTargets);
+    sandbox.stub(fxAccounts.device, "refreshDeviceList").callsFake(() => {
+      recentDeviceList.get(() =>
+        mockTargets.map(({ id, name, type }) => ({ id, name, type }))
+      );
+      sandbox
+        .stub(Weave.Service.clientsEngine, "getClientByFxaDeviceId")
+        .callsFake(fxaDeviceId => {
+          let target = mockTargets.find(c => c.id == fxaDeviceId);
+          return target ? target.clientRecord : null;
+        });
       sandbox
         .stub(Weave.Service.clientsEngine, "getClientType")
         .callsFake(
           id =>
             mockTargets.find(c => c.clientRecord && c.clientRecord.id == id)
               .clientRecord.type
         );
     });
@@ -516,23 +521,26 @@ add_task(async function sendToDevice_not
   });
 });
 
 add_task(async function sendToDevice_noDevices() {
   // Open a tab that's sendable.
   await BrowserTestUtils.withNewTab("http://example.com/", async () => {
     await promiseSyncReady();
     const sandbox = sinon.createSandbox();
-    sandbox.stub(gSync, "syncReady").get(() => true);
-    sandbox
-      .stub(Weave.Service.clientsEngine, "hasSyncedThisSession")
-      .get(() => true);
-    sandbox.stub(Weave.Service.clientsEngine, "fxaDevices").get(() => []);
+    sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => []);
     sandbox.stub(UIState, "get").returns({ status: UIState.STATUS_SIGNED_IN });
     sandbox.stub(gSync, "isSendableURI").returns(true);
+    sandbox.stub(fxAccounts.device, "refreshDeviceList").resolves(true);
+    sandbox
+      .stub(Weave.Service.clientsEngine, "getClientByFxaDeviceId")
+      .callsFake(fxaDeviceId => {
+        let target = mockTargets.find(c => c.id == fxaDeviceId);
+        return target ? target.clientRecord : null;
+      });
     sandbox
       .stub(Weave.Service.clientsEngine, "getClientType")
       .callsFake(
         id =>
           mockTargets.find(c => c.clientRecord && c.clientRecord.id == id)
             .clientRecord.type
       );
 
@@ -591,23 +599,32 @@ add_task(async function sendToDevice_noD
   });
 });
 
 add_task(async function sendToDevice_devices() {
   // Open a tab that's sendable.
   await BrowserTestUtils.withNewTab("http://example.com/", async () => {
     await promiseSyncReady();
     const sandbox = sinon.createSandbox();
-    sandbox.stub(gSync, "syncReady").get(() => true);
     sandbox
-      .stub(Weave.Service.clientsEngine, "hasSyncedThisSession")
-      .get(() => true);
+      .stub(fxAccounts.device, "recentDeviceList")
+      .get(() => mockTargets.map(({ id, name, type }) => ({ id, name, type })));
     sandbox.stub(UIState, "get").returns({ status: UIState.STATUS_SIGNED_IN });
     sandbox.stub(gSync, "isSendableURI").returns(true);
-    sandbox.stub(gSync, "sendTabTargets").get(() => mockTargets);
+    sandbox
+      .stub(fxAccounts.commands.sendTab, "isDeviceCompatible")
+      .returns(true);
+    sandbox.stub(fxAccounts.device, "refreshDeviceList").resolves(true);
+    sandbox.spy(Weave.Service, "sync");
+    sandbox
+      .stub(Weave.Service.clientsEngine, "getClientByFxaDeviceId")
+      .callsFake(fxaDeviceId => {
+        let target = mockTargets.find(c => c.id == fxaDeviceId);
+        return target ? target.clientRecord : null;
+      });
     sandbox
       .stub(Weave.Service.clientsEngine, "getClientType")
       .callsFake(
         id =>
           mockTargets.find(c => c.clientRecord && c.clientRecord.id == id)
             .clientRecord.type
       );
 
@@ -631,33 +648,139 @@ add_task(async function sendToDevice_dev
 
     // The devices should be shown in the subview.
     let expectedItems = [
       {
         className: "pageAction-sendToDevice-notReady",
         display: "none",
         disabled: true,
       },
-    ];
-    for (let target of mockTargets) {
-      expectedItems.push({
+      {
+        attrs: {
+          clientId: "1",
+          label: "bar",
+          clientType: "desktop",
+        },
+      },
+      {
+        attrs: {
+          clientId: "2",
+          label: "baz",
+          clientType: "phone",
+        },
+      },
+      {
         attrs: {
-          clientId: target.id,
-          label: target.name,
-          clientType: target.type,
+          clientId: "0",
+          label: "foo",
+          clientType: "phone",
+        },
+      },
+      {
+        attrs: {
+          clientId: "3",
+          label: "no client record device",
+          clientType: "phone",
+        },
+      },
+      null,
+      {
+        attrs: {
+          label: "Send to All Devices",
         },
+      },
+    ];
+    checkSendToDeviceItems(expectedItems);
+
+    Assert.ok(Weave.Service.sync.notCalled);
+
+    // Done, hide the panel.
+    let hiddenPromise = promisePageActionPanelHidden();
+    BrowserPageActions.panelNode.hidePopup();
+    await hiddenPromise;
+
+    cleanUp();
+  });
+});
+
+add_task(async function sendTabToDevice_syncEnabled() {
+  // Open a tab that's sendable.
+  await BrowserTestUtils.withNewTab("http://example.com/", async () => {
+    await promiseSyncReady();
+    const sandbox = sinon.createSandbox();
+    sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => []);
+    sandbox
+      .stub(UIState, "get")
+      .returns({ status: UIState.STATUS_SIGNED_IN, syncEnabled: true });
+    sandbox.stub(gSync, "isSendableURI").returns(true);
+    sandbox.spy(fxAccounts.device, "refreshDeviceList");
+    sandbox.spy(Weave.Service, "sync");
+    sandbox
+      .stub(Weave.Service.clientsEngine, "getClientByFxaDeviceId")
+      .callsFake(fxaDeviceId => {
+        let target = mockTargets.find(c => c.id == fxaDeviceId);
+        return target ? target.clientRecord : null;
       });
-    }
-    expectedItems.push(null, {
-      attrs: {
-        label: "Send to All Devices",
+    sandbox
+      .stub(Weave.Service.clientsEngine, "getClientType")
+      .callsFake(
+        id =>
+          mockTargets.find(c => c.clientRecord && c.clientRecord.id == id)
+            .clientRecord.type
+      );
+
+    let cleanUp = () => {
+      sandbox.restore();
+    };
+    registerCleanupFunction(cleanUp);
+
+    // Open the panel.
+    await promisePageActionPanelOpen();
+    let sendToDeviceButton = document.getElementById(
+      "pageAction-panel-sendToDevice"
+    );
+    Assert.ok(!sendToDeviceButton.disabled);
+
+    // Click Send to Device.
+    let viewPromise = promisePageActionViewShown();
+    EventUtils.synthesizeMouseAtCenter(sendToDeviceButton, {});
+    let view = await viewPromise;
+    Assert.equal(view.id, "pageAction-panel-sendToDevice-subview");
+
+    let expectedItems = [
+      {
+        className: "pageAction-sendToDevice-notReady",
+        display: "none",
+        disabled: true,
       },
-    });
+      {
+        attrs: {
+          label: "No Devices Connected",
+        },
+        disabled: true,
+      },
+      null,
+      {
+        attrs: {
+          label: "Connect Another Device...",
+        },
+      },
+      {
+        attrs: {
+          label: "Learn About Sending Tabs...",
+        },
+      },
+    ];
     checkSendToDeviceItems(expectedItems);
 
+    Assert.ok(
+      Weave.Service.sync.calledWith({ why: "pageactions", engines: [] })
+    );
+    Assert.ok(fxAccounts.device.refreshDeviceList.notCalled);
+
     // Done, hide the panel.
     let hiddenPromise = promisePageActionPanelHidden();
     BrowserPageActions.panelNode.hidePopup();
     await hiddenPromise;
 
     cleanUp();
   });
 });
@@ -665,25 +788,28 @@ add_task(async function sendToDevice_dev
 add_task(async function sendToDevice_title() {
   // Open two tabs that are sendable.
   await BrowserTestUtils.withNewTab(
     "http://example.com/a",
     async otherBrowser => {
       await BrowserTestUtils.withNewTab("http://example.com/b", async () => {
         await promiseSyncReady();
         const sandbox = sinon.createSandbox();
-        sandbox.stub(gSync, "syncReady").get(() => true);
-        sandbox
-          .stub(Weave.Service.clientsEngine, "hasSyncedThisSession")
-          .get(() => true);
+        sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => []);
         sandbox
           .stub(UIState, "get")
           .returns({ status: UIState.STATUS_SIGNED_IN });
         sandbox.stub(gSync, "isSendableURI").returns(true);
-        sandbox.stub(gSync, "sendTabTargets").get(() => []);
+        sandbox.stub(fxAccounts.device, "refreshDeviceList").resolves(true);
+        sandbox
+          .stub(Weave.Service.clientsEngine, "getClientByFxaDeviceId")
+          .callsFake(fxaDeviceId => {
+            let target = mockTargets.find(c => c.id == fxaDeviceId);
+            return target ? target.clientRecord : null;
+          });
         sandbox
           .stub(Weave.Service.clientsEngine, "getClientType")
           .callsFake(
             id =>
               mockTargets.find(c => c.clientRecord && c.clientRecord.id == id)
                 .clientRecord.type
           );
 
@@ -740,31 +866,39 @@ add_task(async function sendToDevice_tit
   );
 });
 
 add_task(async function sendToDevice_inUrlbar() {
   // Open a tab that's sendable.
   await BrowserTestUtils.withNewTab("http://example.com/", async () => {
     await promiseSyncReady();
     const sandbox = sinon.createSandbox();
-    sandbox.stub(gSync, "syncReady").get(() => true);
     sandbox
-      .stub(Weave.Service.clientsEngine, "hasSyncedThisSession")
-      .get(() => true);
+      .stub(fxAccounts.device, "recentDeviceList")
+      .get(() => mockTargets.map(({ id, name, type }) => ({ id, name, type })));
     sandbox.stub(UIState, "get").returns({ status: UIState.STATUS_SIGNED_IN });
     sandbox.stub(gSync, "isSendableURI").returns(true);
-    sandbox.stub(gSync, "sendTabTargets").get(() => mockTargets);
-    sandbox.stub(gSync, "sendTabToDevice").resolves(true);
+    sandbox
+      .stub(fxAccounts.commands.sendTab, "isDeviceCompatible")
+      .returns(true);
+    sandbox.stub(fxAccounts.device, "refreshDeviceList").resolves(true);
+    sandbox
+      .stub(Weave.Service.clientsEngine, "getClientByFxaDeviceId")
+      .callsFake(fxaDeviceId => {
+        let target = mockTargets.find(c => c.id == fxaDeviceId);
+        return target ? target.clientRecord : null;
+      });
     sandbox
       .stub(Weave.Service.clientsEngine, "getClientType")
       .callsFake(
         id =>
           mockTargets.find(c => c.clientRecord && c.clientRecord.id == id)
             .clientRecord.type
       );
+    sandbox.stub(gSync, "sendTabToDevice").resolves(true);
 
     let cleanUp = () => {
       sandbox.restore();
     };
     registerCleanupFunction(cleanUp);
 
     // Add Send to Device to the urlbar.
     let action = PageActions.actionForID("sendToDevice");
@@ -791,31 +925,51 @@ add_task(async function sendToDevice_inU
 
     // The devices should be shown in the subview.
     let expectedItems = [
       {
         className: "pageAction-sendToDevice-notReady",
         display: "none",
         disabled: true,
       },
-    ];
-    for (let target of mockTargets) {
-      expectedItems.push({
+      {
         attrs: {
-          clientId: target.id,
-          label: target.name,
-          clientType: target.type,
+          clientId: "1",
+          label: "bar",
+          clientType: "desktop",
+        },
+      },
+      {
+        attrs: {
+          clientId: "2",
+          label: "baz",
+          clientType: "phone",
         },
-      });
-    }
-    expectedItems.push(null, {
-      attrs: {
-        label: "Send to All Devices",
+      },
+      {
+        attrs: {
+          clientId: "0",
+          label: "foo",
+          clientType: "phone",
+        },
       },
-    });
+      {
+        attrs: {
+          clientId: "3",
+          label: "no client record device",
+          clientType: "phone",
+        },
+      },
+      null,
+      {
+        attrs: {
+          label: "Send to All Devices",
+        },
+      },
+    ];
     checkSendToDeviceItems(expectedItems, true);
 
     // Get the first device menu item in the panel.
     let bodyID =
       BrowserPageActions._panelViewNodeIDForActionID("sendToDevice", true) +
       "-body";
     let body = document.getElementById(bodyID);
     let deviceMenuItem = body.querySelector(".sendtab-target");
--- a/browser/base/content/test/sync/browser_contextmenu_sendpage.js
+++ b/browser/base/content/test/sync/browser_contextmenu_sendpage.js
@@ -10,18 +10,25 @@ const fxaDevices = [
     availableCommands: { "https://identity.mozilla.com/cmd/open-uri": "baz" },
   },
   { id: 2, name: "Bar", clientRecord: "bar" }, // Legacy send tab target (no availableCommands).
   { id: 3, name: "Homer" }, // Incompatible target.
 ];
 
 add_task(async function setup() {
   await promiseSyncReady();
+  await Services.search.init();
   // gSync.init() is called in a requestIdleCallback. Force its initialization.
   gSync.init();
+  sinon
+    .stub(Weave.Service.clientsEngine, "getClientByFxaDeviceId")
+    .callsFake(fxaDeviceId => {
+      let target = fxaDevices.find(c => c.id == fxaDeviceId);
+      return target ? target.clientRecord : null;
+    });
   sinon.stub(Weave.Service.clientsEngine, "getClientType").returns("desktop");
   await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:mozilla");
 });
 
 add_task(async function test_page_contextmenu() {
   const sandbox = setupSendTabMocks({ fxaDevices });
 
   await openContentContextMenu("#moztext", "context-sendpagetodevice");
@@ -331,16 +338,17 @@ add_task(async function test_page_contex
   );
 });
 
 // We are not going to bother testing the visibility of context-sendlinktodevice
 // since it uses the exact same code.
 // However, browser_contextmenu.js contains tests that verify its presence.
 
 add_task(async function teardown() {
+  Weave.Service.clientsEngine.getClientByFxaDeviceId.restore();
   Weave.Service.clientsEngine.getClientType.restore();
   gBrowser.removeCurrentTab();
 });
 
 function checkPopup(expectedItems = null) {
   const popup = document.getElementById("context-sendpagetodevice-popup");
   if (!expectedItems) {
     is(popup.state, "closed", "Popup should be hidden.");
--- a/browser/base/content/test/sync/browser_contextmenu_sendtab.js
+++ b/browser/base/content/test/sync/browser_contextmenu_sendtab.js
@@ -33,18 +33,25 @@ function updateTabContextMenu(tab = gBro
     tab,
     "TabContextMenu context is the expected tab"
   );
   menu.hidePopup();
 }
 
 add_task(async function setup() {
   await promiseSyncReady();
+  await Services.search.init();
   // gSync.init() is called in a requestIdleCallback. Force its initialization.
   gSync.init();
+  sinon
+    .stub(Weave.Service.clientsEngine, "getClientByFxaDeviceId")
+    .callsFake(fxaDeviceId => {
+      let target = fxaDevices.find(c => c.id == fxaDeviceId);
+      return target ? target.clientRecord : null;
+    });
   sinon.stub(Weave.Service.clientsEngine, "getClientType").returns("desktop");
   await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:mozilla");
   registerCleanupFunction(() => {
     gBrowser.removeCurrentTab();
   });
   is(gBrowser.visibleTabs.length, 2, "there are two visible tabs");
 });
 
@@ -190,16 +197,17 @@ add_task(async function test_tab_context
 
   getter.restore();
   [...document.querySelectorAll(".sync-ui-item")].forEach(
     e => (e.hidden = false)
   );
 });
 
 add_task(async function teardown() {
+  Weave.Service.clientsEngine.getClientByFxaDeviceId.restore();
   Weave.Service.clientsEngine.getClientType.restore();
 });
 
 async function openTabContextMenu(openSubmenuId = null) {
   const contextMenu = document.getElementById("tabContextMenu");
   is(contextMenu.state, "closed", "checking if popup is closed");
 
   const awaitPopupShown = BrowserTestUtils.waitForEvent(
--- a/browser/base/content/test/sync/head.js
+++ b/browser/base/content/test/sync/head.js
@@ -3,28 +3,19 @@ const { sinon } = ChromeUtils.import("re
 
 function promiseSyncReady() {
   let service = Cc["@mozilla.org/weave/service;1"].getService(Ci.nsISupports)
     .wrappedJSObject;
   return service.whenLoaded();
 }
 
 function setupSendTabMocks({
-  syncReady = true,
   fxaDevices = null,
   state = UIState.STATUS_SIGNED_IN,
   isSendableURI = true,
 }) {
   const sandbox = sinon.createSandbox();
-  sandbox.stub(gSync, "syncReady").get(() => syncReady);
-  if (fxaDevices) {
-    // Clone fxaDevices because it gets sorted in-place.
-    sandbox
-      .stub(Weave.Service.clientsEngine, "fxaDevices")
-      .get(() => [...fxaDevices]);
-  }
-  sandbox
-    .stub(Weave.Service.clientsEngine, "hasSyncedThisSession")
-    .get(() => !!fxaDevices);
+  sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => fxaDevices);
   sandbox.stub(UIState, "get").returns({ status: state });
   sandbox.stub(gSync, "isSendableURI").returns(isSendableURI);
+  sandbox.stub(fxAccounts.device, "refreshDeviceList").resolves(true);
   return sandbox;
 }
--- a/browser/components/about/AboutProtectionsHandler.jsm
+++ b/browser/components/about/AboutProtectionsHandler.jsm
@@ -169,35 +169,36 @@ var AboutProtectionsHandler = {
    * Retrieves login data for the user.
    *
    * @return {{ hasFxa: Boolean,
    *            numLogins: Number,
    *            numSyncedDevices: Number }}
    *         The login data.
    */
   async getLoginData() {
-    let syncedDevices = [];
     let hasFxa = false;
 
     try {
       if ((hasFxa = await fxAccounts.accountStatus())) {
-        syncedDevices = await fxAccounts.getDeviceList();
+        await fxAccounts.device.refreshDeviceList();
       }
     } catch (e) {
       Cu.reportError("There was an error fetching login data: ", e.message);
     }
 
     const userFacingLogins =
       Services.logins.countLogins("", "", "") -
       Services.logins.countLogins(FXA_PWDMGR_HOST, null, FXA_PWDMGR_REALM);
 
     return {
       hasFxa,
       numLogins: userFacingLogins,
-      numSyncedDevices: syncedDevices.length,
+      numSyncedDevices: fxAccounts.device.recentDeviceList
+        ? fxAccounts.device.recentDeviceList.length
+        : 0,
     };
   },
 
   /**
    * Retrieves monitor data for the user.
    *
    * @return {{ monitoredEmails: Number,
    *            numBreaches: Number,
--- a/services/fxaccounts/FxAccounts.jsm
+++ b/services/fxaccounts/FxAccounts.jsm
@@ -417,20 +417,16 @@ class FxAccounts {
   _withCurrentAccountState(func) {
     return this._internal.withCurrentAccountState(func);
   }
 
   _withVerifiedAccountState(func) {
     return this._internal.withVerifiedAccountState(func);
   }
 
-  getDeviceList() {
-    return this._internal.getDeviceList();
-  }
-
   /**
    * Returns an array listing all the OAuth clients
    * connected to the authenticated user's account.
    * Devices and web sessions are not included.
    *
    * @typedef {Object} AttachedClient
    * @property {String} id - OAuth `client_id` of the client.
    * @property {String} name - Client name. e.g. Firefox Monitor.
@@ -1096,36 +1092,35 @@ FxAccountsInternal.prototype = {
             );
           }
         );
       })
       .catch(err => this._handleTokenError(err))
       .then(result => currentState.resolve(result));
   },
 
-  getDeviceList() {
-    return this.device.getDeviceList();
-  },
-
   /*
    * Reset state such that any previous flow is canceled.
    */
   abortExistingFlow() {
     if (this.currentTimer) {
       log.debug("Polling aborted; Another user signing in");
       clearTimeout(this.currentTimer);
       this.currentTimer = 0;
     }
     if (this._profile) {
       this._profile.tearDown();
       this._profile = null;
     }
     if (this._commands) {
       this._commands = null;
     }
+    if (this._device) {
+      this._device.reset();
+    }
     // We "abort" the accountState and assume our caller is about to throw it
     // away and replace it with a new one.
     return this.currentAccountState.abort();
   },
 
   accountStatus: function accountStatus() {
     return this.currentAccountState.getUserAccountData().then(data => {
       if (!data) {
--- a/services/fxaccounts/FxAccountsCommands.js
+++ b/services/fxaccounts/FxAccountsCommands.js
@@ -123,22 +123,29 @@ class FxAccountsCommands {
     const opts = { index };
     if (limit != null) {
       opts.limit = limit;
     }
     return client.getCommands(sessionToken, opts);
   }
 
   async _handleCommands(messages) {
-    const fxaDevices = await this._fxai.getDeviceList();
+    try {
+      await this._fxai.device.refreshDeviceList();
+    } catch (e) {
+      log.warn("Error refreshing device list", e);
+    }
     // We debounce multiple incoming tabs so we show a single notification.
     const tabsReceived = [];
     for (const { data } of messages) {
       const { command, payload, sender: senderId } = data;
-      const sender = senderId ? fxaDevices.find(d => d.id == senderId) : null;
+      const sender =
+        senderId && this._fxai.device.recentDeviceList
+          ? this._fxai.device.recentDeviceList.find(d => d.id == senderId)
+          : null;
       if (!sender) {
         log.warn(
           "Incoming command is from an unknown device (maybe disconnected?)"
         );
       }
       switch (command) {
         case COMMAND_SENDTAB:
           try {
--- a/services/fxaccounts/FxAccountsDevice.jsm
+++ b/services/fxaccounts/FxAccountsDevice.jsm
@@ -9,16 +9,18 @@ const { XPCOMUtils } = ChromeUtils.impor
   "resource://gre/modules/XPCOMUtils.jsm"
 );
 
 const {
   log,
   ERRNO_DEVICE_SESSION_CONFLICT,
   ERRNO_UNKNOWN_DEVICE,
   ON_NEW_DEVICE_ID,
+  ON_DEVICE_CONNECTED_NOTIFICATION,
+  ON_DEVICE_DISCONNECTED_NOTIFICATION,
 } = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
 
 const { DEVICE_TYPE_DESKTOP } = ChromeUtils.import(
   "resource://services-sync/constants.js"
 );
 
 const { PREF_ACCOUNT_ROOT } = ChromeUtils.import(
   "resource://gre/modules/FxAccountsCommon.js"
@@ -39,19 +41,37 @@ XPCOMUtils.defineLazyPreferenceGetter(
 );
 
 const PREF_DEPRECATED_DEVICE_NAME = "services.sync.client.name";
 
 // Everything to do with FxA devices.
 class FxAccountsDevice {
   constructor(fxai) {
     this._fxai = fxai;
+    this._deviceListCache = null;
+
+    // The generation avoids a race where we'll cache a stale device list if the
+    // user signs out during a background refresh. It works like this: during a
+    // refresh, we store the current generation, fetch the new list from the
+    // server, and compare the stored generation to the current one. Since we
+    // increment the generation on reset, we know that the fetched list isn't
+    // valid if the generations are different.
+    this._generation = 0;
+
     // The current version of the device registration, we use this to re-register
     // devices after we update what we send on device registration.
     this.DEVICE_REGISTRATION_VERSION = 2;
+
+    // This is to avoid multiple sequential syncs ending up calling
+    // this expensive endpoint multiple times in a row.
+    this.TIME_BETWEEN_FXA_DEVICES_FETCH_MS = 1 * 60 * 1000; // 1 minute
+
+    // Invalidate our cached device list when a device is connected or disconnected.
+    Services.obs.addObserver(this, ON_DEVICE_CONNECTED_NOTIFICATION, true);
+    Services.obs.addObserver(this, ON_DEVICE_DISCONNECTED_NOTIFICATION, true);
   }
 
   async getLocalId() {
     let data = await this._fxai.currentAccountState.getUserAccountData();
     if (!data) {
       // Without a signed-in user, there can be no device id.
       return null;
     }
@@ -170,32 +190,97 @@ class FxAccountsDevice {
       !device.registeredCommandsKeys ||
       !CommonUtils.arrayEqual(
         device.registeredCommandsKeys,
         availableCommandsKeys
       )
     );
   }
 
-  getDeviceList() {
-    return this._fxai.withVerifiedAccountState(async state => {
-      let accountData = await state.getUserAccountData();
-
-      const devices = await this._fxai.fxAccountsClient.getDeviceList(
-        accountData.sessionToken
-      );
+  /**
+   * Returns the most recently fetched device list, or `null` if the list
+   * hasn't been fetched yet. This is synchronous, so that consumers like
+   * Send Tab can render the device list right away, without waiting for
+   * it to refresh.
+   *
+   * @type {?Array}
+   */
+  get recentDeviceList() {
+    return this._deviceListCache ? this._deviceListCache.devices : null;
+  }
 
-      // Check if our push registration is still good.
-      const ourDevice = devices.find(device => device.isCurrentDevice);
-      if (ourDevice.pushEndpointExpired) {
-        await this._fxai.fxaPushService.unsubscribe();
-        await this._registerOrUpdateDevice(accountData);
-      }
-      return devices;
-    });
+  /**
+   * Refreshes the device list. After this function returns, consumers can
+   * access the new list using the `recentDeviceList` getter. Note that
+   * multiple concurrent calls to `refreshDeviceList` will only refresh the
+   * list once.
+   *
+   * @param  {Boolean} [options.ignoreCached]
+   *         If `true`, forces a refresh, even if the cached device list is
+   *         still fresh. Defaults to `false`.
+   * @return {Promise<Boolean>}
+   *         `true` if the list was refreshed, `false` if the cached list is
+   *         fresh. Rejects if an error occurs refreshing the list or device
+   *         push registration.
+   */
+  async refreshDeviceList({ ignoreCached = false } = {}) {
+    if (this._fetchAndCacheDeviceListPromise) {
+      // If we're already refreshing the list in the background, let that
+      // finish.
+      return this._fetchAndCacheDeviceListPromise;
+    }
+    if (ignoreCached || !this._deviceListCache) {
+      return this._fetchAndCacheDeviceList();
+    }
+    if (
+      this._fxai.now() - this._deviceListCache.lastFetch <
+      this.TIME_BETWEEN_FXA_DEVICES_FETCH_MS
+    ) {
+      // If our recent device list is still fresh, skip the request to
+      // refresh it.
+      return false;
+    }
+    return this._fetchAndCacheDeviceList();
+  }
+
+  async _fetchAndCacheDeviceList() {
+    if (this._fetchAndCacheDeviceListPromise) {
+      return this._fetchAndCacheDeviceListPromise;
+    }
+    let generation = this._generation;
+    return (this._fetchAndCacheDeviceListPromise = this._fxai
+      .withVerifiedAccountState(async state => {
+        let accountData = await state.getUserAccountData([
+          "sessionToken",
+          "device",
+        ]);
+
+        let devices = await this._fxai.fxAccountsClient.getDeviceList(
+          accountData.sessionToken
+        );
+        if (generation != this._generation) {
+          throw new Error("Another user has signed in");
+        }
+        this._deviceListCache = {
+          lastFetch: this._fxai.now(),
+          devices,
+        };
+
+        // Check if our push registration is still good.
+        const ourDevice = devices.find(device => device.isCurrentDevice);
+        if (ourDevice.pushEndpointExpired) {
+          await this._fxai.fxaPushService.unsubscribe();
+          await this._registerOrUpdateDevice(accountData);
+        }
+
+        return true;
+      })
+      .finally(_ => {
+        this._fetchAndCacheDeviceListPromise = null;
+      }));
   }
 
   async updateDeviceRegistration() {
     try {
       const signedInUser = await this._fxai.currentAccountState.getUserAccountData();
       if (signedInUser) {
         await this._registerOrUpdateDevice(signedInUser);
       }
@@ -358,15 +443,53 @@ class FxAccountsDevice {
       });
     } catch (secondError) {
       log.error(
         "failed to reset the device registration version, device registration won't be retried",
         secondError
       );
     }
   }
+
+  reset() {
+    this._deviceListCache = null;
+    this._generation++;
+    this._fetchAndCacheDeviceListPromise = null;
+  }
+
+  // Kick off a background refresh when a device is connected or disconnected.
+  observe(subject, topic, data) {
+    switch (topic) {
+      case ON_DEVICE_CONNECTED_NOTIFICATION:
+        this._fetchAndCacheDeviceList().catch(error => {
+          log.warn(
+            "failed to refresh devices after connecting a new device",
+            error
+          );
+        });
+        break;
+      case ON_DEVICE_DISCONNECTED_NOTIFICATION:
+        let json = JSON.parse(data);
+        if (!json.isLocalDevice) {
+          // If we're the device being disconnected, don't bother fetching a new
+          // list, since our session token is now invalid.
+          this._fetchAndCacheDeviceList().catch(error => {
+            log.warn(
+              "failed to refresh devices after disconnecting a device",
+              error
+            );
+          });
+        }
+        break;
+    }
+  }
 }
 
+FxAccountsDevice.prototype.QueryInterface = ChromeUtils.generateQI([
+  Ci.nsIObserver,
+  Ci.nsISupportsWeakReference,
+]);
+
 function urlsafeBase64Encode(buffer) {
   return ChromeUtils.base64URLEncode(new Uint8Array(buffer), { pad: false });
 }
 
 var EXPORTED_SYMBOLS = ["FxAccountsDevice"];
--- a/services/fxaccounts/tests/xpcshell/test_accounts_device_registration.js
+++ b/services/fxaccounts/tests/xpcshell/test_accounts_device_registration.js
@@ -4,20 +4,25 @@
 "use strict";
 
 const { FxAccounts } = ChromeUtils.import(
   "resource://gre/modules/FxAccounts.jsm"
 );
 const { FxAccountsClient } = ChromeUtils.import(
   "resource://gre/modules/FxAccountsClient.jsm"
 );
+const { FxAccountsDevice } = ChromeUtils.import(
+  "resource://gre/modules/FxAccountsDevice.jsm"
+);
 const {
   ERRNO_DEVICE_SESSION_CONFLICT,
   ERRNO_TOO_MANY_CLIENT_REQUESTS,
   ERRNO_UNKNOWN_DEVICE,
+  ON_DEVICE_CONNECTED_NOTIFICATION,
+  ON_DEVICE_DISCONNECTED_NOTIFICATION,
 } = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
 var { AccountState } = ChromeUtils.import(
   "resource://gre/modules/FxAccounts.jsm",
   null
 );
 
 initTestLogging("Trace");
 
@@ -630,22 +635,147 @@ add_task(async function test_devicelist_
         name: "foo",
         type: "desktop",
         isCurrentDevice: true,
         pushEndpointExpired: true,
       },
     ]);
   };
 
-  await fxa.getDeviceList();
+  await fxa.device.refreshDeviceList();
 
   Assert.equal(spy.getDeviceList.count, 1);
   Assert.equal(spy.updateDevice.count, 1);
 });
 
+add_task(async function test_refreshDeviceList() {
+  let credentials = getTestUser("baz");
+
+  let storage = new MockStorageManager();
+  storage.initialize(credentials);
+  let state = new AccountState(storage);
+
+  let fxAccountsClient = new MockFxAccountsClient({
+    id: "deviceAAAAAA",
+    name: "iPhone",
+    type: "phone",
+    sessionToken: credentials.sessionToken,
+  });
+  let spy = {
+    getDeviceList: { count: 0 },
+  };
+  fxAccountsClient.getDeviceList = (function(old) {
+    return function getDeviceList() {
+      spy.getDeviceList.count += 1;
+      return old.apply(this, arguments);
+    };
+  })(fxAccountsClient.getDeviceList);
+  let fxai = {
+    _now: Date.now(),
+    fxAccountsClient,
+    now() {
+      return this._now;
+    },
+    withVerifiedAccountState(func) {
+      // Ensure `func` is called asynchronously.
+      return Promise.resolve().then(_ => func(state));
+    },
+    fxaPushService: null,
+  };
+  let device = new FxAccountsDevice(fxai);
+
+  Assert.equal(
+    device.recentDeviceList,
+    null,
+    "Should not have device list initially"
+  );
+  Assert.ok(await device.refreshDeviceList(), "Should refresh list");
+  Assert.deepEqual(
+    device.recentDeviceList,
+    [
+      {
+        id: "deviceAAAAAA",
+        name: "iPhone",
+        type: "phone",
+        isCurrentDevice: true,
+      },
+    ],
+    "Should fetch device list"
+  );
+  Assert.equal(
+    spy.getDeviceList.count,
+    1,
+    "Should make request to refresh list"
+  );
+  Assert.ok(
+    !(await device.refreshDeviceList()),
+    "Should not refresh device list if fresh"
+  );
+
+  fxai._now += device.TIME_BETWEEN_FXA_DEVICES_FETCH_MS;
+
+  let refreshPromise = device.refreshDeviceList();
+  let secondRefreshPromise = device.refreshDeviceList();
+  Assert.ok(
+    await Promise.all([refreshPromise, secondRefreshPromise]),
+    "Should refresh list if stale"
+  );
+  Assert.equal(
+    spy.getDeviceList.count,
+    2,
+    "Should only make one request if called with pending request"
+  );
+
+  device.observe(null, ON_DEVICE_CONNECTED_NOTIFICATION);
+  await device.refreshDeviceList();
+  Assert.equal(
+    spy.getDeviceList.count,
+    3,
+    "Should refresh device list after connecting new device"
+  );
+  device.observe(
+    null,
+    ON_DEVICE_DISCONNECTED_NOTIFICATION,
+    JSON.stringify({ isLocalDevice: false })
+  );
+  await device.refreshDeviceList();
+  Assert.equal(
+    spy.getDeviceList.count,
+    4,
+    "Should refresh device list after disconnecting device"
+  );
+  device.observe(
+    null,
+    ON_DEVICE_DISCONNECTED_NOTIFICATION,
+    JSON.stringify({ isLocalDevice: true })
+  );
+  await device.refreshDeviceList();
+  Assert.equal(
+    spy.getDeviceList.count,
+    4,
+    "Should not refresh device list after disconnecting this device"
+  );
+
+  let refreshBeforeResetPromise = device.refreshDeviceList({
+    ignoreCached: true,
+  });
+  device.reset();
+  await Assert.rejects(refreshBeforeResetPromise, /Another user has signed in/);
+
+  Assert.equal(
+    device.recentDeviceList,
+    null,
+    "Should clear device list after resetting"
+  );
+  Assert.ok(
+    await device.refreshDeviceList(),
+    "Should fetch new list after resetting"
+  );
+});
+
 function expandHex(two_hex) {
   // Return a 64-character hex string, encoding 32 identical bytes.
   let eight_hex = two_hex + two_hex + two_hex + two_hex;
   let thirtytwo_hex = eight_hex + eight_hex + eight_hex + eight_hex;
   return thirtytwo_hex + thirtytwo_hex;
 }
 
 function expandBytes(two_hex) {
--- a/services/sync/modules/engines/clients.js
+++ b/services/sync/modules/engines/clients.js
@@ -60,20 +60,16 @@ ChromeUtils.defineModuleGetter(
 
 const CLIENTS_TTL = 1814400; // 21 days
 const CLIENTS_TTL_REFRESH = 604800; // 7 days
 const STALE_CLIENT_REMOTE_AGE = 604800; // 7 days
 
 // TTL of the message sent to another device when sending a tab
 const NOTIFY_TAB_SENT_TTL_SECS = 1 * 3600; // 1 hour
 
-// This is to avoid multiple sequential syncs ending up calling
-// this expensive endpoint multiple times in a row.
-const TIME_BETWEEN_FXA_DEVICES_FETCH_MS = 10 * 1000;
-
 // Reasons behind sending collection_changed push notifications.
 const COLLECTION_MODIFIED_REASON_SENDTAB = "sendtab";
 const COLLECTION_MODIFIED_REASON_FIRSTSYNC = "firstsync";
 
 const SUPPORTED_PROTOCOL_VERSIONS = [SYNC_API_VERSION];
 const LAST_MODIFIED_ON_PROCESS_COMMAND_PREF =
   "services.sync.clients.lastModifiedOnProcessCommands";
 
@@ -154,20 +150,16 @@ ClientEngine.prototype = {
 
   get lastRecordUpload() {
     return Svc.Prefs.get(this.name + ".lastRecordUpload", 0);
   },
   set lastRecordUpload(value) {
     Svc.Prefs.set(this.name + ".lastRecordUpload", Math.floor(value));
   },
 
-  get fxaDevices() {
-    return this._fxaDevices;
-  },
-
   get remoteClients() {
     // return all non-stale clients for external consumption.
     return Object.values(this._store._remoteClients).filter(v => !v.stale);
   },
 
   remoteClient(id) {
     let client = this._store._remoteClients[id];
     return client && !client.stale ? client : null;
@@ -254,16 +246,29 @@ ClientEngine.prototype = {
 
   getClientFxaDeviceId(id) {
     if (this._store._remoteClients[id]) {
       return this._store._remoteClients[id].fxaDeviceId;
     }
     return null;
   },
 
+  getClientByFxaDeviceId(fxaDeviceId) {
+    for (let id in this._store._remoteClients) {
+      let client = this._store._remoteClients[id];
+      if (client.stale) {
+        continue;
+      }
+      if (client.fxaDeviceId == fxaDeviceId) {
+        return client;
+      }
+    }
+    return null;
+  },
+
   getClientType(id) {
     const client = this._store._remoteClients[id];
     if (client.type == DEVICE_TYPE_DESKTOP) {
       return "desktop";
     }
     if (client.formfactor && client.formfactor.includes("tablet")) {
       return "tablet";
     }
@@ -382,44 +387,31 @@ ClientEngine.prototype = {
         client.stale = true;
       } else {
         seenDeviceIds.add(client.fxaDeviceId);
       }
     }
   },
 
   async _fetchFxADevices() {
-    const now = new Date().getTime();
-    if (
-      (this._lastFxADevicesFetch || 0) + TIME_BETWEEN_FXA_DEVICES_FETCH_MS >=
-      now
-    ) {
-      return;
+    try {
+      await this.fxAccounts.device.refreshDeviceList();
+    } catch (e) {
+      this._log.error("Could not refresh the FxA device list", e);
     }
-    const remoteClients = Object.values(this.remoteClients);
-    try {
-      this._fxaDevices = await this.fxAccounts.getDeviceList();
-      for (const device of this._fxaDevices) {
-        device.clientRecord = remoteClients.find(
-          c => c.fxaDeviceId == device.id
-        );
-      }
-    } catch (e) {
-      this._log.error("Could not retrieve the FxA device list", e);
-      this._fxaDevices = [];
-    }
-    this._lastFxADevicesFetch = now;
 
     // We assume that clients not present in the FxA Device Manager list have been
     // disconnected and so are stale
     this._log.debug("Refreshing the known stale clients list");
     let localClients = Object.values(this._store._remoteClients)
       .filter(client => client.fxaDeviceId) // iOS client records don't have fxaDeviceId
       .map(client => client.fxaDeviceId);
-    const fxaClients = this._fxaDevices.map(device => device.id);
+    const fxaClients = this.fxAccounts.device.recentDeviceList
+      ? this.fxAccounts.device.recentDeviceList.map(device => device.id)
+      : [];
     this._knownStaleFxADeviceIds = Utils.arraySub(localClients, fxaClients);
   },
 
   async _syncStartup() {
     // Reupload new client record periodically.
     if (Date.now() / 1000 - this.lastRecordUpload > CLIENTS_TTL_REFRESH) {
       await this._tracker.addChangedID(this.localID);
     }
--- a/services/sync/tests/unit/test_clients_engine.js
+++ b/services/sync/tests/unit/test_clients_engine.js
@@ -985,19 +985,20 @@ add_task(async function test_clients_not
         return fxAccounts.device.getLocalId();
       },
       getLocalName() {
         return fxAccounts.device.getLocalName();
       },
       getLocalType() {
         return fxAccounts.device.getLocalType();
       },
-    },
-    getDeviceList() {
-      return Promise.resolve([{ id: remoteId }]);
+      recentDeviceList: [{ id: remoteId }],
+      refreshDeviceList() {
+        return Promise.resolve(true);
+      },
     },
   };
 
   try {
     _("Syncing.");
     await syncClientsEngine(server);
 
     ok(!engine._store._remoteClients[remoteId].stale);
@@ -1064,19 +1065,20 @@ add_task(async function test_dupe_device
         return fxAccounts.device.getLocalId();
       },
       getLocalName() {
         return fxAccounts.device.getLocalName();
       },
       getLocalType() {
         return fxAccounts.device.getLocalType();
       },
-    },
-    getDeviceList() {
-      return Promise.resolve([{ id: remoteDeviceId }]);
+      recentDeviceList: [{ id: remoteDeviceId }],
+      refreshDeviceList() {
+        return Promise.resolve(true);
+      },
     },
   };
 
   try {
     _("Syncing.");
     await syncClientsEngine(server);
 
     ok(engine._store._remoteClients[remoteId].stale);