From 0289eeada16d6821293e8b53860281f82f32c96c Mon Sep 17 00:00:00 2001 From: Gregory Pappas Date: Tue, 20 Jun 2023 23:59:08 +0000 Subject: [PATCH] Bug 1809094 - Implement tab.autoDiscardable property r=robwu,geckoview-reviewers,extension-reviewers,Gijs,owlish,tabbrowser-reviewers,dao Differential Revision: https://phabricator.services.mozilla.com/D166440 --- browser/base/content/tabbrowser-tab.js | 13 ++ browser/base/content/tabbrowser.js | 4 + .../components/extensions/parent/ext-browser.js | 4 + browser/components/extensions/parent/ext-tabs.js | 11 ++ browser/components/extensions/schemas/tabs.json | 21 +++ .../components/extensions/test/browser/browser.ini | 1 + .../browser/browser_ext_tabs_autoDiscardable.js | 177 +++++++++++++++++++++ browser/modules/TabUnloader.sys.mjs | 2 +- .../android/components/extensions/ext-android.js | 6 + .../components/extensions/schemas/tabs.json | 5 + .../extensions/test/mochitest/mochitest.ini | 1 + .../mochitest/test_ext_tabs_autoDiscardable.html | 48 ++++++ .../components/extensions/parent/ext-tabs-base.js | 14 ++ 13 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_autoDiscardable.js create mode 100644 mobile/android/components/extensions/test/mochitest/test_ext_tabs_autoDiscardable.html diff --git a/browser/base/content/tabbrowser-tab.js b/browser/base/content/tabbrowser-tab.js index d518dee81d25..157f338bd99d 100644 --- a/browser/base/content/tabbrowser-tab.js +++ b/browser/base/content/tabbrowser-tab.js @@ -154,6 +154,15 @@ gBrowser._tabAttrModified(this, ["attention"]); } + set undiscardable(val) { + if (val == this.hasAttribute("undiscardable")) { + return; + } + + this.toggleAttribute("undiscardable", val); + gBrowser._tabAttrModified(this, ["undiscardable"]); + } + set _visuallySelected(val) { if (val == (this.getAttribute("visuallyselected") == "true")) { return; @@ -224,6 +233,10 @@ return this.getAttribute("activemedia-blocked") == "true"; } + get undiscardable() { + return this.hasAttribute("undiscardable"); + } + get isEmpty() { // Determines if a tab is "empty", usually used in the context of determining // if it's ok to close the tab. diff --git a/browser/base/content/tabbrowser.js b/browser/base/content/tabbrowser.js index 06d31ea7e7ef..cd585d5834d5 100644 --- a/browser/base/content/tabbrowser.js +++ b/browser/base/content/tabbrowser.js @@ -4547,6 +4547,10 @@ } modifiedAttrs.push("muted"); } + if (aOtherTab.hasAttribute("undiscardable")) { + aOurTab.setAttribute("undiscardable", "true"); + modifiedAttrs.push("undiscardable"); + } if (aOtherTab.hasAttribute("soundplaying")) { aOurTab.setAttribute("soundplaying", "true"); modifiedAttrs.push("soundplaying"); diff --git a/browser/components/extensions/parent/ext-browser.js b/browser/components/extensions/parent/ext-browser.js index 6f377471b00a..8eaee6f506dd 100644 --- a/browser/components/extensions/parent/ext-browser.js +++ b/browser/components/extensions/parent/ext-browser.js @@ -747,6 +747,10 @@ class Tab extends TabBase { return this.nativeTab.soundPlaying; } + get autoDiscardable() { + return !this.nativeTab.undiscardable; + } + get browser() { return this.nativeTab.linkedBrowser; } diff --git a/browser/components/extensions/parent/ext-tabs.js b/browser/components/extensions/parent/ext-tabs.js index 09507b6060f9..dfd051c3a69b 100644 --- a/browser/components/extensions/parent/ext-tabs.js +++ b/browser/components/extensions/parent/ext-tabs.js @@ -150,10 +150,12 @@ const allAttrs = new Set([ "mutedInfo", "sharingState", "title", + "autoDiscardable", ]); const allProperties = new Set([ "attention", "audible", + "autoDiscardable", "discarded", "favIconUrl", "hidden", @@ -419,6 +421,12 @@ this.tabs = class extends ExtensionAPIPersistent { ) { needed.push("audible"); } + if ( + changed.includes("undiscardable") && + filter.properties.has("autoDiscardable") + ) { + needed.push("autoDiscardable"); + } if (changed.includes("label") && filter.properties.has("title")) { needed.push("title"); } @@ -898,6 +906,9 @@ this.tabs = class extends ExtensionAPIPersistent { if (updateProperties.active) { tabbrowser.selectedTab = nativeTab; } + if (updateProperties.autoDiscardable !== null) { + nativeTab.undiscardable = !updateProperties.autoDiscardable; + } if (updateProperties.highlighted !== null) { if (updateProperties.highlighted) { if (!nativeTab.selected && !nativeTab.multiselected) { diff --git a/browser/components/extensions/schemas/tabs.json b/browser/components/extensions/schemas/tabs.json index e5c1c074ac3e..03108ccbd154 100644 --- a/browser/components/extensions/schemas/tabs.json +++ b/browser/components/extensions/schemas/tabs.json @@ -139,6 +139,11 @@ "optional": true, "description": "Whether the tab has produced sound over the past couple of seconds (but it might not be heard if also muted). Equivalent to whether the speaker audio indicator is showing." }, + "autoDiscardable": { + "type": "boolean", + "optional": true, + "description": "Whether the tab can be discarded automatically by the browser when resources are low." + }, "mutedInfo": { "$ref": "MutedInfo", "optional": true, @@ -425,6 +430,7 @@ "enum": [ "attention", "audible", + "autoDiscardable", "discarded", "favIconUrl", "hidden", @@ -741,6 +747,11 @@ "optional": true, "description": "Whether the tabs are audible." }, + "autoDiscardable": { + "type": "boolean", + "optional": true, + "description": "Whether the tab can be discarded automatically by the browser when resources are low." + }, "muted": { "type": "boolean", "optional": true, @@ -938,6 +949,11 @@ "optional": true, "description": "Whether the tab should be active. Does not affect whether the window is focused (see $(ref:windows.update))." }, + "autoDiscardable": { + "type": "boolean", + "optional": true, + "description": "Whether the tab can be discarded automatically by the browser when resources are low." + }, "highlighted": { "type": "boolean", "optional": true, @@ -1615,6 +1631,11 @@ "optional": true, "description": "The tab's new audible state." }, + "autoDiscardable": { + "type": "boolean", + "optional": true, + "description": "The tab's new autoDiscardable state." + }, "discarded": { "type": "boolean", "optional": true, diff --git a/browser/components/extensions/test/browser/browser.ini b/browser/components/extensions/test/browser/browser.ini index 2a484c74a912..cae823091772 100644 --- a/browser/components/extensions/test/browser/browser.ini +++ b/browser/components/extensions/test/browser/browser.ini @@ -296,6 +296,7 @@ skip-if = [browser_ext_tabs_attention.js] https_first_disabled = true [browser_ext_tabs_audio.js] +[browser_ext_tabs_autoDiscardable.js] [browser_ext_tabs_containerIsolation.js] https_first_disabled = true [browser_ext_tabs_cookieStoreId.js] diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_autoDiscardable.js b/browser/components/extensions/test/browser/browser_ext_tabs_autoDiscardable.js new file mode 100644 index 000000000000..4ee18d43c421 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_autoDiscardable.js @@ -0,0 +1,177 @@ +"use strict"; + +add_task(async function test_autoDiscardable() { + let files = { + "schema.json": JSON.stringify([ + { + namespace: "experiments", + functions: [ + { + name: "unload", + type: "function", + async: "callback", + description: + "Unload the least recently used tab using Firefox's built-in tab unloader mechanism", + parameters: [], + }, + ], + }, + ]), + "parent.js": () => { + const { TabUnloader } = ChromeUtils.importESModule( + "resource:///modules/TabUnloader.sys.mjs" + ); + const { ExtensionError } = ExtensionUtils; + this.experiments = class extends ExtensionAPI { + getAPI(context) { + return { + experiments: { + async unload() { + try { + await TabUnloader.unloadLeastRecentlyUsedTab(null); + } catch (error) { + // We need to do this, otherwise failures won't bubble up to the test properly. + throw ExtensionError(error); + } + }, + }, + }; + } + }; + }, + }; + + async function background() { + let firstTab = await browser.tabs.create({ + active: false, + url: "https://example.org/", + }); + + // Make sure setting and getting works properly + browser.test.assertTrue( + firstTab.autoDiscardable, + "autoDiscardable should always be true by default" + ); + let result = await browser.tabs.update(firstTab.id, { + autoDiscardable: false, + }); + browser.test.assertFalse( + result.autoDiscardable, + "autoDiscardable should be false after setting it as such" + ); + result = await browser.tabs.update(firstTab.id, { + autoDiscardable: true, + }); + browser.test.assertTrue( + result.autoDiscardable, + "autoDiscardable should be true after setting it as such" + ); + result = await browser.tabs.update(firstTab.id, { + autoDiscardable: false, + }); + browser.test.assertFalse( + result.autoDiscardable, + "autoDiscardable should be false after setting it as such" + ); + + // Make sure the tab can't be unloaded when autoDiscardable is false + await browser.experiments.unload(); + result = await browser.tabs.get(firstTab.id); + browser.test.assertFalse( + result.discarded, + "Tab should not unload when autoDiscardable is false" + ); + + // Make sure the tab CAN be unloaded when autoDiscardable is true + await browser.tabs.update(firstTab.id, { + autoDiscardable: true, + }); + await browser.experiments.unload(); + result = await browser.tabs.get(firstTab.id); + browser.test.assertTrue( + result.discarded, + "Tab should unload when autoDiscardable is true" + ); + + // Make sure filtering for discardable tabs works properly + result = await browser.tabs.query({ autoDiscardable: true }); + browser.test.assertEq( + 2, + result.length, + "tabs.query should return 2 when autoDiscardable is true " + ); + await browser.tabs.update(firstTab.id, { + autoDiscardable: false, + }); + result = await browser.tabs.query({ autoDiscardable: true }); + browser.test.assertEq( + 1, + result.length, + "tabs.query should return 1 when autoDiscardable is false" + ); + + let onUpdatedPromise = {}; + onUpdatedPromise.promise = new Promise( + resolve => (onUpdatedPromise.resolve = resolve) + ); + + // Make sure onUpdated works + async function testOnUpdatedEvent(autoDiscardable) { + browser.test.log(`Testing autoDiscardable = ${autoDiscardable}`); + let onUpdated; + let promise = new Promise(resolve => { + onUpdated = (tabId, changeInfo, tabInfo) => { + browser.test.assertEq( + firstTab.id, + tabId, + "The updated tab's ID should match the correct tab" + ); + browser.test.assertDeepEq( + { autoDiscardable }, + changeInfo, + "The updated tab's changeInfo should be correct" + ); + browser.test.assertEq( + tabInfo.autoDiscardable, + autoDiscardable, + "The updated tab's tabInfo should be correct" + ); + resolve(); + }; + }); + browser.tabs.onUpdated.addListener(onUpdated, { + properties: ["autoDiscardable"], + }); + await browser.tabs.update(firstTab.id, { autoDiscardable }); + await promise; + browser.tabs.onUpdated.removeListener(onUpdated); + } + + await testOnUpdatedEvent(true); + await testOnUpdatedEvent(false); + + await browser.tabs.remove(firstTab.id); // Cleanup + browser.test.notifyPass("autoDiscardable"); + } + let extension = ExtensionTestUtils.loadExtension({ + isPrivileged: true, + manifest: { + permissions: ["tabs"], + experiment_apis: { + experiments: { + schema: "schema.json", + parent: { + scopes: ["addon_parent"], + script: "parent.js", + paths: [["experiments"]], + }, + }, + }, + }, + background, + files, + }); + await extension.startup(); + await extension.awaitFinish("autoDiscardable"); + await extension.unload(); +}); diff --git a/browser/modules/TabUnloader.sys.mjs b/browser/modules/TabUnloader.sys.mjs index 16ef56ee2837..a0c1233f274b 100644 --- a/browser/modules/TabUnloader.sys.mjs +++ b/browser/modules/TabUnloader.sys.mjs @@ -52,7 +52,7 @@ let CRITERIA_WEIGHT = 1; */ let DefaultTabUnloaderMethods = { isNonDiscardable(tab, weight) { - if (tab.selected) { + if (tab.undiscardable || tab.selected) { return weight; } diff --git a/mobile/android/components/extensions/ext-android.js b/mobile/android/components/extensions/ext-android.js index a417811c8c3a..cd9a4fc03c77 100644 --- a/mobile/android/components/extensions/ext-android.js +++ b/mobile/android/components/extensions/ext-android.js @@ -391,6 +391,12 @@ class Tab extends TabBase { return false; } + get autoDiscardable() { + // This property reflects whether the browser is allowed to auto-discard. + // Since extensions cannot do so on Android, we return true here. + return true; + } + get sharingState() { return { screen: undefined, diff --git a/mobile/android/components/extensions/schemas/tabs.json b/mobile/android/components/extensions/schemas/tabs.json index 6d304affa112..05f83be0329f 100644 --- a/mobile/android/components/extensions/schemas/tabs.json +++ b/mobile/android/components/extensions/schemas/tabs.json @@ -140,6 +140,11 @@ "optional": true, "description": "Whether the tab has produced sound over the past couple of seconds (but it might not be heard if also muted). Equivalent to whether the speaker audio indicator is showing." }, + "autoDiscardable": { + "type": "boolean", + "optional": true, + "description": "Whether the tab can be discarded automatically by the browser when resources are low." + }, "mutedInfo": { "$ref": "MutedInfo", "optional": true, diff --git a/mobile/android/components/extensions/test/mochitest/mochitest.ini b/mobile/android/components/extensions/test/mochitest/mochitest.ini index e7a4bdf0e35d..15d54724430a 100644 --- a/mobile/android/components/extensions/test/mochitest/mochitest.ini +++ b/mobile/android/components/extensions/test/mochitest/mochitest.ini @@ -18,6 +18,7 @@ prefs = [test_ext_all_apis.html] [test_ext_downloads_event_page.html] [test_ext_tab_runtimeConnect.html] +[test_ext_tabs_autoDiscardable.html] [test_ext_tabs_create.html] [test_ext_tabs_events.html] skip-if = diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_autoDiscardable.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_autoDiscardable.html new file mode 100644 index 000000000000..fedab6414e40 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_autoDiscardable.html @@ -0,0 +1,48 @@ + + + + + + autoDiscardable test + + + + + + + + + + + + diff --git a/toolkit/components/extensions/parent/ext-tabs-base.js b/toolkit/components/extensions/parent/ext-tabs-base.js index d75d4f649ce7..6240769a6e94 100644 --- a/toolkit/components/extensions/parent/ext-tabs-base.js +++ b/toolkit/components/extensions/parent/ext-tabs-base.js @@ -286,6 +286,16 @@ class TabBase { } /** + * @property {boolean} autoDiscardable + * Returns true if the tab can be discarded on memory pressure, false otherwise. + * @readonly + * @abstract + */ + get autoDiscardable() { + throw new Error("Not implemented"); + } + + /** * @property {XULElement} browser * Returns the XUL browser for the given tab. * @readonly @@ -515,6 +525,8 @@ class TabBase { * 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 {boolean} [queryInfo.autoDiscardable] + * Matches against the exact value of the tab's `autoDiscardable` attribute. * @param {string} [queryInfo.cookieStoreId] * Matches against the exact value of the tab's `cookieStoreId` attribute. * @param {boolean} [queryInfo.discarded] @@ -552,6 +564,7 @@ class TabBase { const PROPS = [ "active", "audible", + "autoDiscardable", "discarded", "hidden", "highlighted", @@ -637,6 +650,7 @@ class TabBase { height: this.height, lastAccessed: this.lastAccessed, audible: this.audible, + autoDiscardable: this.autoDiscardable, mutedInfo: this.mutedInfo, isArticle: this.isArticle, isInReaderMode: this.isInReaderMode, -- 2.11.4.GIT