Bug 1384515 add show/hide tabs api, r?aswan draft
authorShane Caraveo <scaraveo@mozilla.com>
Tue, 24 Oct 2017 17:57:33 -0700
changeset 685684 a512fd8ff7aa6a250df67eb1955b6a01421b6f2e
parent 683668 5fa7c257f3a41c01a60929b124e603b63e9daee7
child 737223 be81992bedc1fe0e9cf1d9f4e3a36f19c921155b
push id86011
push usermixedpuppy@gmail.com
push dateWed, 25 Oct 2017 00:59:45 +0000
reviewersaswan
bugs1384515
milestone58.0a1
Bug 1384515 add show/hide tabs api, r?aswan MozReview-Commit-ID: EONTO7Bywhw
browser/base/content/tabbrowser.xml
browser/components/extensions/ext-browser.js
browser/components/extensions/ext-tabs.js
browser/components/extensions/schemas/tabs.json
browser/components/extensions/test/browser/browser-common.ini
browser/components/extensions/test/browser/browser_ext_tabs_hide.js
toolkit/components/extensions/ext-tabs-base.js
--- a/browser/base/content/tabbrowser.xml
+++ b/browser/base/content/tabbrowser.xml
@@ -2479,31 +2479,66 @@
 
             var evt = new CustomEvent("TabBrowserInserted",
               { bubbles: true, detail: { insertedOnTabCreation: aInsertedOnTabCreation } });
             aTab.dispatchEvent(evt);
           ]]>
         </body>
       </method>
 
+      <method name="canHideTab">
+        <parameter name="aTab"/>
+        <body>
+          <![CDATA[
+            "use strict";
+            // TODO Bug 1384515 followup.  This is very similar to discardBrowser
+            // logic except that if the browser is already discarded
+            // (!browser.connected) we allow hiding the tab.
+            let browser = aTab.linkedBrowser;
+            return aTab && browser && !aTab.selected && !aTab.closing &&
+                    !this._windowIsClosing && browser.isRemoteBrowser &&
+                    !(browser.frameLoader && browser.frameLoader.tabParent.hasBeforeUnload) &&
+                    !this._isOwningTab(aTab);
+          ]]>
+        </body>
+      </method>
+
+      <method name="_isOwningTab">
+        <parameter name="aTab"/>
+        <body>
+          <![CDATA[
+            "use strict";
+            // TODO Bug 1384515 followup.  Owner tabs may close other tabs when
+            // discarded, so for now we do not discard owners.
+            for (let otherTab of this.tabs) {
+              if ("owner" in otherTab && otherTab.owner == aTab) {
+                return true;
+              }
+            }
+            return false;
+          ]]>
+        </body>
+      </method>
+
       <method name="discardBrowser">
         <parameter name="aBrowser"/>
         <body>
           <![CDATA[
             "use strict";
 
             let tab = this.getTabForBrowser(aBrowser);
 
             if (!tab ||
                 tab.selected ||
                 tab.closing ||
                 this._windowIsClosing ||
                 !aBrowser.isConnected ||
                 !aBrowser.isRemoteBrowser ||
-                aBrowser.frameLoader.tabParent.hasBeforeUnload) {
+                aBrowser.frameLoader.tabParent.hasBeforeUnload ||
+                this._isOwningTab(tab)) {
               return;
             }
 
             // Set browser parameters for when browser is restored.  Also remove
             // listeners and set up lazy restore data in SessionStore. This must
             // be done before aBrowser is destroyed and removed from the document.
             tab._browserParams = { uriIsAboutBlank: aBrowser.currentURI.spec == "about:blank",
                                    remoteType: aBrowser.remoteType,
@@ -3805,16 +3840,47 @@
               this.showTab(tab);
           }
 
           this.tabContainer._handleTabSelect(true);
         ]]>
         </body>
       </method>
 
+      <method name="showTabs">
+        <parameter name="aTabs"/>
+        <body>
+        <![CDATA[
+          for (let tab of aTabs) {
+            if (tab.ownerGlobal.gBrowser == this) {
+              this.showTab(tab);
+            }
+          }
+
+          this.tabContainer._handleTabSelect(true);
+        ]]>
+        </body>
+      </method>
+
+      <method name="hideTabs">
+        <parameter name="aTabs"/>
+        <parameter name="aDiscard"/>
+        <body>
+        <![CDATA[
+          for (let tab of aTabs) {
+            if (tab.ownerGlobal.gBrowser == this) {
+              this.hideTab(tab, aDiscard);
+            }
+          }
+
+          this.tabContainer._handleTabSelect(true);
+        ]]>
+        </body>
+      </method>
+
       <method name="showTab">
         <parameter name="aTab"/>
         <body>
         <![CDATA[
           if (aTab.hidden) {
             aTab.removeAttribute("hidden");
             this._visibleTabs = null; // invalidate cache
 
@@ -3827,20 +3893,24 @@
             aTab.dispatchEvent(event);
           }
         ]]>
         </body>
       </method>
 
       <method name="hideTab">
         <parameter name="aTab"/>
+        <parameter name="aDiscard"/>
         <body>
         <![CDATA[
           if (!aTab.hidden && !aTab.pinned && !aTab.selected &&
-              !aTab.closing) {
+              !aTab.closing && this.canHideTab(aTab)) {
+            if (aDiscard) {
+              this.discardBrowser(aTab.linkedBrowser);
+            }
             aTab.setAttribute("hidden", "true");
             this._visibleTabs = null; // invalidate cache
 
             this.tabContainer.adjustTabstrip();
 
             this.tabContainer._setPositionalAttributes();
 
             let event = document.createEvent("Events");
--- a/browser/components/extensions/ext-browser.js
+++ b/browser/components/extensions/ext-browser.js
@@ -597,16 +597,20 @@ class Tab extends TabBase {
   }
 
   get frameLoader() {
     // If we don't have a frameLoader yet, just return a dummy with no width and
     // height.
     return super.frameLoader || {lazyWidth: 0, lazyHeight: 0};
   }
 
+  get hidden() {
+    return this.nativeTab.hidden;
+  }
+
   get cookieStoreId() {
     return getCookieStoreIdForTab(this, this.nativeTab);
   }
 
   get openerTabId() {
     let opener = this.nativeTab.openerTab;
     if (opener && opener.parentNode && opener.ownerDocument == this.nativeTab.ownerDocument) {
       return tabTracker.getId(opener);
@@ -691,30 +695,33 @@ class Tab extends TabBase {
    * @param {DOMWindow} [window = null]
    *        The browser window which the tab belonged to before it was closed.
    *        May be null if the window the tab belonged to no longer exists.
    *
    * @returns {Object}
    * @static
    */
   static convertFromSessionStoreClosedData(extension, tabData, window = null) {
+    let state = tabData.state || tabData;
+
     let result = {
       sessionId: String(tabData.closedId),
       index: tabData.pos ? tabData.pos : 0,
       windowId: window && windowTracker.getId(window),
       highlighted: false,
       active: false,
       pinned: false,
-      incognito: Boolean(tabData.state && tabData.state.isPrivate),
-      lastAccessed: tabData.state ? tabData.state.lastAccessed : tabData.lastAccessed,
+      hidden: state.hidden,
+      incognito: Boolean(state.isPrivate),
+      lastAccessed: state.lastAccessed,
     };
 
     if (extension.tabManager.hasTabPermission(tabData)) {
-      let entries = tabData.state ? tabData.state.entries : tabData.entries;
-      let lastTabIndex = tabData.state ? tabData.state.index : tabData.index;
+      let entries = state.entries;
+      let lastTabIndex = state.index;
       // We need to take lastTabIndex - 1 because the index in the tab data is
       // 1-based rather than 0-based.
       let entry = entries[lastTabIndex - 1];
       result.url = entry.url;
       result.title = entry.title;
       if (tabData.image) {
         result.favIconUrl = tabData.image;
       }
--- a/browser/components/extensions/ext-tabs.js
+++ b/browser/components/extensions/ext-tabs.js
@@ -98,16 +98,48 @@ this.tabs = class extends ExtensionAPI {
         tab = tabManager.getWrapper(tabTracker.activeTab);
       }
 
       await tabListener.awaitTabReady(tab.nativeTab);
 
       return tab;
     }
 
+    function getBrowserTabsMap(tabIds, hidden = true) {
+      let containers = new Map();
+      tabIds.forEach(tabId => {
+        let tab = tabTracker.getTab(tabId);
+        if (tab.hidden == hidden) {
+          if (!containers.has(tab.ownerGlobal.gBrowser)) {
+            containers.set(tab.ownerGlobal.gBrowser, [tab]);
+          } else {
+            containers.get(tab.ownerGlobal.gBrowser).push(tab);
+          }
+        }
+      });
+      return containers;
+    }
+
+    if (extension.hasPermission("tabHide")) {
+      extension.callOnClose({
+        close: () => {
+          if (extension.shutdownReason == "ADDON_DISABLE" ||
+              extension.shutdownReason == "ADDON_UNINSTALL") {
+            // Show all hidden tabs if a tab managing extension is uninstalled or
+            // disabled.  If a user has more than one, the extensions will need to
+            // self-manage re-hiding tabs.
+            let tabIds = Array.from(tabTracker._tabIds.keys());
+            for (let [gBrowser, tabs] of getBrowserTabsMap(tabIds, true).entries()) {
+              gBrowser.showTabs(tabs);
+            }
+          }
+        },
+      });
+    }
+
     let self = {
       tabs: {
         onActivated: new EventManager(context, "tabs.onActivated", fire => {
           let listener = (eventName, event) => {
             fire.async(event);
           };
 
           tabTracker.on("tab-activated", listener);
@@ -264,16 +296,20 @@ this.tabs = class extends ExtensionAPI {
               }
             } else if (event.type == "TabPinned") {
               needed.push("pinned");
             } else if (event.type == "TabUnpinned") {
               needed.push("pinned");
             } else if (event.type == "TabBrowserInserted" &&
                        !event.detail.insertedOnTabCreation) {
               needed.push("discarded");
+            } else if (event.type == "TabShow") {
+              needed.push("hidden");
+            } else if (event.type == "TabHide") {
+              needed.push("hidden");
             }
 
             let tab = tabManager.getWrapper(event.originalTarget);
             let changeInfo = {};
             for (let prop of needed) {
               changeInfo[prop] = tab[prop];
             }
 
@@ -301,25 +337,29 @@ this.tabs = class extends ExtensionAPI {
             fireForTab(tab, {isArticle: event.data.isArticle});
           };
 
           windowTracker.addListener("status", statusListener);
           windowTracker.addListener("TabAttrModified", listener);
           windowTracker.addListener("TabPinned", listener);
           windowTracker.addListener("TabUnpinned", listener);
           windowTracker.addListener("TabBrowserInserted", listener);
+          windowTracker.addListener("TabShow", listener);
+          windowTracker.addListener("TabHide", listener);
 
           tabTracker.on("tab-isarticle", isArticleChangeListener);
 
           return () => {
             windowTracker.removeListener("status", statusListener);
             windowTracker.removeListener("TabAttrModified", listener);
             windowTracker.removeListener("TabPinned", listener);
             windowTracker.removeListener("TabUnpinned", listener);
             windowTracker.removeListener("TabBrowserInserted", listener);
+            windowTracker.removeListener("TabShow", listener);
+            windowTracker.removeListener("TabHide", listener);
             tabTracker.off("tab-isarticle", isArticleChangeListener);
           };
         }).api(),
 
         create(createProperties) {
           return new Promise((resolve, reject) => {
             let window = createProperties.windowId !== null ?
               windowTracker.getWindow(createProperties.windowId, context) :
@@ -933,13 +973,42 @@ this.tabs = class extends ExtensionAPI {
           let tab = await promiseTabWhenReady(tabId);
           if (!tab.isInReaderMode && !tab.isArticle) {
             throw new ExtensionError("The specified tab cannot be placed into reader mode.");
           }
           tab = getTabOrActive(tabId);
 
           tab.linkedBrowser.messageManager.sendAsyncMessage("Reader:ToggleReaderMode");
         },
+
+        show(tabIds) {
+          if (!Array.isArray(tabIds)) {
+            tabIds = [tabIds];
+          }
+          for (let [gBrowser, tabs] of getBrowserTabsMap(tabIds, true).entries()) {
+            gBrowser.showTabs(tabs);
+          }
+        },
+
+        hide(tabIds) {
+          if (!extension.hasPermission("tabHide")) {
+            throw new ExtensionError("tabHide permission required.");
+          }
+          if (!Array.isArray(tabIds)) {
+            tabIds = [tabIds];
+          }
+          for (let [gBrowser, tabs] of getBrowserTabsMap(tabIds, false).entries()) {
+            // TODO Bug 1384515 followup.  We additionally discard hidden tabs
+            // which places the tab back into a suspended lazy state.  This will
+            // become an option later.
+            gBrowser.hideTabs(tabs, true);
+          }
+        },
+
+        isHideable(tabId) {
+          let nativeTab = tabTracker.getTab(tabId);
+          return nativeTab.ownerGlobal.gBrowser.canHideTab(nativeTab);
+        },
       },
     };
     return self;
   }
 };
--- a/browser/components/extensions/schemas/tabs.json
+++ b/browser/components/extensions/schemas/tabs.json
@@ -7,17 +7,18 @@
     "namespace": "manifest",
     "types": [
       {
         "$extend": "OptionalPermission",
         "choices": [{
           "type": "string",
           "enum": [
             "activeTab",
-            "tabs"
+            "tabs",
+            "tabHide"
           ]
         }]
       }
     ]
   },
   {
     "namespace": "tabs",
     "description": "Use the <code>browser.tabs</code> API to interact with the browser's tab system. You can use this API to create, modify, and rearrange tabs in the browser.",
@@ -70,16 +71,17 @@
           "url": {"type": "string", "optional": true, "permissions": ["tabs"], "description": "The URL the tab is displaying. This property is only present if the extension's manifest includes the <code>\"tabs\"</code> permission."},
           "title": {"type": "string", "optional": true, "permissions": ["tabs"], "description": "The title of the tab. This property is only present if the extension's manifest includes the <code>\"tabs\"</code> permission."},
           "favIconUrl": {"type": "string", "optional": true, "permissions": ["tabs"], "description": "The URL of the tab's favicon. This property is only present if the extension's manifest includes the <code>\"tabs\"</code> permission. It may also be an empty string if the tab is loading."},
           "status": {"type": "string", "optional": true, "description": "Either <em>loading</em> or <em>complete</em>."},
           "discarded": {"type": "boolean", "optional": true, "description": "True while the tab is not loaded with content."},
           "incognito": {"type": "boolean", "description": "Whether the tab is in an incognito window."},
           "width": {"type": "integer", "optional": true, "description": "The width of the tab in pixels."},
           "height": {"type": "integer", "optional": true, "description": "The height of the tab in pixels."},
+          "hidden": {"type": "boolean", "optional": true, "description": "True if the tab is hidden."},
           "sessionId": {"type": "string", "optional": true, "description": "The session ID used to uniquely identify a Tab obtained from the $(ref:sessions) API."},
           "cookieStoreId": {"type": "string", "optional": true, "description": "The CookieStoreId used for the tab."},
           "isArticle": {"type": "boolean", "optional": true, "description": "Whether the document in the tab can be rendered in reader mode."},
           "isInReaderMode": {"type": "boolean", "optional": true, "description": "Whether the document in the tab is being rendered in reader mode."}
         }
       },
       {
         "id": "ZoomSettingsMode",
@@ -592,16 +594,21 @@
                 "optional": true,
                 "description": "Whether the tabs have completed loading."
               },
               "discarded": {
                 "type": "boolean",
                 "optional": true,
                 "description": "True while the tabs are not loaded with content."
               },
+              "hidden": {
+                "type": "boolean",
+                "optional": true,
+                "description": "True while the tabs are hidden."
+              },
               "title": {
                 "type": "string",
                 "optional": true,
                 "description": "Match page titles against a pattern."
               },
               "url": {
                 "choices": [
                   {"type": "string"},
@@ -1187,16 +1194,62 @@
               {
                 "type": "string",
                 "name": "status",
                 "description": "Save status: saved, replaced, canceled, not_saved, not_replaced."
               }
             ]
           }
         ]
+      },
+      {
+        "name": "show",
+        "type": "function",
+        "description": "Shows one or more tabs.",
+        "async": true,
+        "parameters": [
+          {
+            "name": "tabIds",
+            "description": "The TAB ID or list of TAB IDs to show.",
+            "choices": [
+              {"type": "integer", "minimum": 0},
+              {"type": "array", "items": {"type": "integer", "minimum": 0}}
+            ]
+          }
+        ]
+      },
+      {
+        "name": "hide",
+        "type": "function",
+        "description": "Hides one or more tabs. The <code>\"tabHide\"</code> permission is required to hide tabs.",
+        "async": true,
+        "parameters": [
+          {
+            "name": "tabIds",
+            "description": "The TAB ID or list of TAB IDs to hide.",
+            "choices": [
+              {"type": "integer", "minimum": 0},
+              {"type": "array", "items": {"type": "integer", "minimum": 0}}
+            ]
+          }
+        ]
+      },
+      {
+        "name": "isHideable",
+        "type": "function",
+        "description": "Returns whether the tab can be hidden or not.",
+        "async": true,
+        "parameters": [
+          {
+            "type": "integer",
+            "name": "tabId",
+            "minimum": 0,
+            "description": "The ID of the tab to check."
+          }
+        ]
       }
     ],
     "events": [
       {
         "name": "onCreated",
         "type": "function",
         "description": "Fired when a tab is created. Note that the tab's URL may not be set at the time this event fired, but you can listen to onUpdated events to be notified when a URL is set.",
         "parameters": [
--- a/browser/components/extensions/test/browser/browser-common.ini
+++ b/browser/components/extensions/test/browser/browser-common.ini
@@ -139,16 +139,17 @@ skip-if = os == "linux" && debug && bits
 [browser_ext_tabs_events.js]
 [browser_ext_tabs_executeScript.js]
 [browser_ext_tabs_executeScript_good.js]
 [browser_ext_tabs_executeScript_bad.js]
 [browser_ext_tabs_executeScript_multiple.js]
 [browser_ext_tabs_executeScript_no_create.js]
 [browser_ext_tabs_executeScript_runAt.js]
 [browser_ext_tabs_getCurrent.js]
+[browser_ext_tabs_hide.js]
 [browser_ext_tabs_insertCSS.js]
 [browser_ext_tabs_lastAccessed.js]
 [browser_ext_tabs_lazy.js]
 [browser_ext_tabs_removeCSS.js]
 [browser_ext_tabs_move_array.js]
 [browser_ext_tabs_move_window.js]
 [browser_ext_tabs_move_window_multiple.js]
 [browser_ext_tabs_move_window_pinned.js]
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/browser_ext_tabs_hide.js
@@ -0,0 +1,155 @@
+"use strict";
+
+function* BrowserWindowIterator() {
+  let windowsEnum = Services.wm.getEnumerator("navigator:browser");
+  while (windowsEnum.hasMoreElements()) {
+    let currentWindow = windowsEnum.getNext();
+    if (!currentWindow.closed) {
+      yield currentWindow;
+    }
+  }
+}
+
+// Close all but our primary window.
+function promiseAllButPrimaryWindowClosed() {
+  let windows = [];
+  for (let win of BrowserWindowIterator()) {
+    if (win != window) {
+      windows.push(win);
+    }
+  }
+
+  return Promise.all(windows.map(BrowserTestUtils.closeWindow));
+}
+
+function promiseNotification(topic) {
+  return new Promise((resolve, reject) => {
+    function observe(subject, topic, data) {
+      Services.obs.removeObserver(observe, topic);
+      resolve();
+    }
+    Services.obs.addObserver(observe, topic);
+  });
+}
+
+add_task(async function test_tabs_showhide() {
+  async function background() {
+    browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
+      if ("hidden" in changeInfo) {
+        browser.test.sendMessage("changeInfo", {tabId, changeInfo});
+      }
+    });
+    browser.test.onMessage.addListener(async (msg, data) => {
+      switch (msg) {
+        case "hideall": {
+          let tabs = await browser.tabs.query({hidden: false});
+          let ids = tabs.map(tab => tab.id);
+          for (let id of ids) {
+            let tab = await browser.tabs.get(id);
+            // selected tab cannot be hidden
+            browser.test.assertEq(!tab.highlighted, await browser.tabs.isHideable(tab.id), "tab hideable value ok");
+            browser.test.assertEq(false, tab.hidden, "tab is visible");
+          }
+          browser.tabs.hide(ids);
+          break;
+        }
+        case "showall": {
+          let tabs = await browser.tabs.query({hidden: true});
+          let ids = tabs.map(tab => tab.id);
+          for (let id of ids) {
+            let tab = await browser.tabs.get(id);
+            browser.test.assertEq(true, tab.hidden, "tab is hidden");
+          }
+          browser.tabs.show(ids);
+          break;
+        }
+      }
+    });
+  }
+
+  let extdata = {
+    manifest: {permissions: ["tabs", "tabHide"]},
+    useAddonManager: "temporary",
+    background,
+  };
+  let extension = ExtensionTestUtils.loadExtension(extdata);
+  await extension.startup();
+
+  let sessData = {
+    windows: [{
+      tabs: [
+        {entries: [{url: "about:blank"}]},
+        {entries: [{url: "https://example.com/"}]},
+        {entries: [{url: "https://mochi.test:8888/"}]},
+      ],
+    }, {
+      tabs: [
+        {entries: [{url: "about:blank"}]},
+        {entries: [{url: "http://test1.example.com/"}]},
+      ],
+    }],
+  };
+
+  let restored = promiseNotification("sessionstore-browser-state-restored");
+  SessionStore.setBrowserState(JSON.stringify(sessData));
+  await restored;
+
+  extension.sendMessage("hideall");
+  await extension.awaitMessage("changeInfo");
+  await extension.awaitMessage("changeInfo");
+  await extension.awaitMessage("changeInfo");
+
+  let otherwin;
+  for (let win of BrowserWindowIterator()) {
+    if (win != window) {
+      otherwin = win;
+    }
+    let tabs = Array.from(win.gBrowser.tabs.values());
+    ok(!tabs[0].hidden, "first tab not hidden");
+    for (let i = 1; i < tabs.length; i++) {
+      ok(tabs[i].hidden, "tab hidden value is correct");
+    }
+  }
+
+  // Test closing the last visible tab, the next tab which is hidden should become
+  // visible.
+  ok(!otherwin.gBrowser.selectedTab.hidden, "selected tab is not hidden");
+  await Promise.all([
+    BrowserTestUtils.removeTab(otherwin.gBrowser.selectedTab),
+    extension.awaitMessage("changeInfo"),
+  ]);
+  ok(!otherwin.gBrowser.selectedTab.hidden, "tab was unhidden");
+
+  extension.sendMessage("showall");
+  await extension.awaitMessage("changeInfo");
+  await extension.awaitMessage("changeInfo");
+
+  for (let win of BrowserWindowIterator()) {
+    let tabs = Array.from(win.gBrowser.tabs.values());
+    for (let i = 0; i < tabs.length; i++) {
+      ok(!tabs[i].hidden, "tab hidden value is correct");
+    }
+  }
+
+  // Test that opener tabs cannot be hidden.
+  // TODO Bug 1384515 Followup, when we can optionally not discard we'll need
+  // to update the test to check it is hidden but not discarded.
+  let selectedTab = otherwin.gBrowser.selectedTab;
+  otherwin.gBrowser.addTab("http://test1.example.com/", {ownerTab: selectedTab, openerBrowser: selectedTab.linkedBrowser});
+  extension.sendMessage("hideall");
+  await extension.awaitMessage("changeInfo");
+  await extension.awaitMessage("changeInfo");
+  await extension.awaitMessage("changeInfo");
+  ok(!selectedTab.hidden, "opener tab is not hidden");
+
+  // close tabs in first window
+  let tabs = Array.from(gBrowser.tabs.values());
+  tabs.shift();
+  for (let tab of tabs) {
+    await BrowserTestUtils.removeTab(tab);
+  }
+  // close second window
+  await promiseAllButPrimaryWindowClosed();
+
+  await extension.unload();
+});
--- a/toolkit/components/extensions/ext-tabs-base.js
+++ b/toolkit/components/extensions/ext-tabs-base.js
@@ -448,16 +448,18 @@ class TabBase {
    * @param {object} queryInfo
    *        The query info against which to match.
    * @param {boolean} [queryInfo.active]
    *        Matches against the exact value of the tab's `active` attribute.
    * @param {boolean} [queryInfo.audible]
    *        Matches against the exact value of the tab's `audible` attribute.
    * @param {string} [queryInfo.cookieStoreId]
    *        Matches against the exact value of the tab's `cookieStoreId` attribute.
+   * @param {boolean} [queryInfo.hidden]
+   *        Matches against the exact value of the tab's `hidden` attribute.
    * @param {boolean} [queryInfo.highlighted]
    *        Matches against the exact value of the tab's `highlighted` attribute.
    * @param {integer} [queryInfo.index]
    *        Matches against the exact value of the tab's `index` attribute.
    * @param {boolean} [queryInfo.muted]
    *        Matches against the exact value of the tab's `mutedInfo.muted` attribute.
    * @param {boolean} [queryInfo.pinned]
    *        Matches against the exact value of the tab's `pinned` attribute.
@@ -470,17 +472,17 @@ class TabBase {
    *        than an exact value match, and will do so in the future.
    * @param {MatchPattern} [queryInfo.url]
    *        Requires the tab's URL to match the given MatchPattern object.
    *
    * @returns {boolean}
    *        True if the tab matches the query.
    */
   matches(queryInfo) {
-    const PROPS = ["active", "audible", "cookieStoreId", "highlighted", "index", "openerTabId", "pinned", "status", "title"];
+    const PROPS = ["active", "audible", "cookieStoreId", "hidden", "highlighted", "index", "openerTabId", "pinned", "status", "title"];
 
     if (PROPS.some(prop => queryInfo[prop] != null && queryInfo[prop] !== this[prop])) {
       return false;
     }
 
     if (queryInfo.muted !== null) {
       if (queryInfo.muted !== this.mutedInfo.muted) {
         return false;
@@ -508,16 +510,17 @@ class TabBase {
     let result = {
       id: this.id,
       index: this.index,
       windowId: this.windowId,
       highlighted: this.selected,
       active: this.selected,
       pinned: this.pinned,
       status: this.status,
+      hidden: this.hidden,
       discarded: this.discarded,
       incognito: this.incognito,
       width: this.width,
       height: this.height,
       lastAccessed: this.lastAccessed,
       audible: this.audible,
       mutedInfo: this.mutedInfo,
       isArticle: this.isArticle,