Skip to content

Commit c83e0b9

Browse files
committed
TabRecency: disallow operations until state is loaded from storage
This approach eliminates edge cases and race conditions, and is easier to follow.
1 parent 8b01abe commit c83e0b9

File tree

4 files changed

+98
-45
lines changed

4 files changed

+98
-45
lines changed

background_scripts/completion.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,7 @@ class DomainCompleter {
479479
// If the query is empty, then return a list of open tabs, sorted by recency.
480480
class TabCompleter {
481481
async filter({ queryTerms }) {
482+
await BgUtils.tabRecency.init();
482483
// We search all tabs, not just those in the current window.
483484
const tabs = await chrome.tabs.query({});
484485
const results = tabs.filter((tab) => RankingUtils.matches(queryTerms, tab.url, tab.title));

background_scripts/main.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,8 @@ const BackgroundCommands = {
316316
await removeTabsRelative("both", request);
317317
},
318318

319-
visitPreviousTab({ count, tab }) {
319+
async visitPreviousTab({ count, tab }) {
320+
await BgUtils.tabRecency.init();
320321
let tabIds = BgUtils.tabRecency.getTabsByRecency();
321322
tabIds = tabIds.filter((tabId) => tabId !== tab.id);
322323
if (tabIds.length > 0) {

background_scripts/tab_recency.js

Lines changed: 61 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,33 +5,56 @@
55
// The values are persisted to chrome.storage.session so that they're not lost when the extension's
66
// background page is unloaded.
77
//
8+
// Callers must await TabRecency.init before calling recencyScore or getTabsByRecency.
9+
//
810
// In theory, the browser's tab.lastAccessed timestamp field should allow us to sort tabs by
911
// recency, but in practice it does not work across several edge cases. See the comments on #4368.
1012
class TabRecency {
1113
constructor() {
1214
this.counter = 1;
1315
this.tabIdToCounter = {};
16+
this.loaded = false;
17+
this.queuedActions = [];
1418
}
1519

1620
// Add listeners to chrome.tabs, and load the index from session storage.
17-
// If tabs are accessed before we finish loading chrome.storage.session, the in-memory stage and
18-
// the session state is merged.
19-
init() {
20-
chrome.tabs.onActivated.addListener((activeInfo) => this.register(activeInfo.tabId));
21-
chrome.tabs.onRemoved.addListener((tabId) => this.deregister(tabId));
21+
async init() {
22+
if (this.initPromise) {
23+
await this.initPromise;
24+
return;
25+
}
26+
let resolveFn;
27+
this.initPromise = new Promise((resolve, _reject) => {
28+
resolveFn = resolve;
29+
});
30+
31+
chrome.tabs.onActivated.addListener((activeInfo) => {
32+
this.queueAction("register", activeInfo.tabId);
33+
});
34+
chrome.tabs.onRemoved.addListener((tabId) => {
35+
this.queueAction("deregister", tabId);
36+
});
2237

2338
chrome.tabs.onReplaced.addListener((addedTabId, removedTabId) => {
24-
this.deregister(removedTabId);
25-
this.register(addedTabId);
39+
this.queueAction("deregister", removedTabId);
40+
this.queueAction("register", addedTabId);
2641
});
2742

2843
chrome.windows.onFocusChanged.addListener(async (windowId) => {
2944
if (windowId == chrome.windows.WINDOW_ID_NONE) return;
3045
const tabs = await chrome.tabs.query({ windowId, active: true });
31-
if (tabs[0]) this.register(tabs[0].id);
46+
if (tabs[0]) {
47+
this.queueAction("register", tabs[0].id);
48+
}
3249
});
3350

34-
this.loadFromStorage();
51+
await this.loadFromStorage();
52+
while (this.queuedActions.length > 0) {
53+
const [action, tabId] = this.queuedActions.shift();
54+
this.handleAction(action, tabId);
55+
}
56+
this.loaded = true;
57+
resolveFn();
3558
}
3659

3760
// Loads the index from session storage.
@@ -45,9 +68,10 @@ class TabRecency {
4568
for (const counter of Object.values(storage.tabRecency)) {
4669
if (maxCounter < counter) maxCounter = counter;
4770
}
48-
if (this.counter < maxCounter + 1) {
49-
this.counter = maxCounter + 1;
71+
if (this.counter < maxCounter) {
72+
this.counter = maxCounter;
5073
}
74+
5175
// Tabs loaded from storage should be considered accessed less recently than any tab tracked in
5276
// memory, so increase all of the in-memory tabs's counters by maxCounter.
5377
for (const [id, counter] of Object.entries(this.tabIdToCounter)) {
@@ -56,22 +80,41 @@ class TabRecency {
5680
if (this.counter < newCounter) this.counter = newCounter;
5781
}
5882

59-
const combined = Object.assign({}, storage.tabRecency, this.tabIdToCounter);
83+
this.tabIdToCounter = Object.assign({}, storage.tabRecency);
6084

61-
// Remove any tab IDs which may be no longer present.
85+
// Remove any tab IDs which aren't currently loaded.
6286
const tabIds = new Set(tabs.map((t) => t.id));
63-
for (const id in combined) {
87+
for (const id in this.tabIdToCounter) {
6488
if (!tabIds.has(parseInt(id))) {
65-
delete combined[id];
89+
delete this.tabIdToCounter[id];
6690
}
6791
}
68-
this.tabIdToCounter = combined;
6992
}
7093

7194
async saveToStorage() {
7295
await chrome.storage.session.set({ tabRecency: this.tabIdToCounter });
7396
}
7497

98+
// - action: "register" or "unregister".
99+
queueAction(action, tabId) {
100+
if (!this.loaded) {
101+
this.queuedActions.push([action, tabId]);
102+
} else {
103+
this.handleAction(action, tabId);
104+
}
105+
}
106+
107+
// - action: "register" or "unregister".
108+
handleAction(action, tabId) {
109+
if (action == "register") {
110+
this.register(tabId);
111+
} else if (action == "deregister") {
112+
this.deregister(tabId);
113+
} else {
114+
throw new Error(`Unexpected action type: ${action}`);
115+
}
116+
}
117+
75118
register(tabId) {
76119
this.counter++;
77120
this.tabIdToCounter[tabId] = this.counter;
@@ -85,6 +128,7 @@ class TabRecency {
85128

86129
// Recently-visited tabs get a higher score (except the current tab, which gets a low score).
87130
recencyScore(tabId) {
131+
if (!this.loaded) throw new Error("TabRecency hasn't yet been loaded.");
88132
const tabCounter = this.tabIdToCounter[tabId];
89133
const isCurrentTab = tabCounter == this.counter;
90134
if (isCurrentTab) return 0;
@@ -93,6 +137,7 @@ class TabRecency {
93137

94138
// Returns a list of tab Ids sorted by recency, most recent tab first.
95139
getTabsByRecency() {
140+
if (!this.loaded) throw new Error("TabRecency hasn't yet been loaded.");
96141
const ids = Object.keys(this.tabIdToCounter);
97142
ids.sort((a, b) => this.tabIdToCounter[b] - this.tabIdToCounter[a]);
98143
return ids.map((id) => parseInt(id));

tests/unit_tests/tab_recency_test.js

Lines changed: 34 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@ context("TabRecency", () => {
77
setup(() => tabRecency = new TabRecency());
88

99
context("order", () => {
10-
setup(() => {
11-
tabRecency.register(1);
12-
tabRecency.register(2);
13-
tabRecency.register(3);
14-
tabRecency.register(4);
15-
tabRecency.deregister(4);
16-
tabRecency.register(2);
10+
setup(async () => {
11+
stub(chrome.tabs, "query", () => Promise.resolve([]));
12+
await tabRecency.init();
13+
tabRecency.queueAction("register", (1));
14+
tabRecency.queueAction("register", (2));
15+
tabRecency.queueAction("register", (3));
16+
tabRecency.queueAction("register", (4));
17+
tabRecency.queueAction("deregister", (4));
18+
tabRecency.queueAction("register", (2));
1719
});
1820

1921
should("have the correct entries in the correct order", () => {
@@ -29,48 +31,52 @@ context("TabRecency", () => {
2931
});
3032
});
3133

34+
should("navigate actions are queued until state from storage is loaded", async () => {
35+
let onActivated;
36+
stub(chrome.tabs.onActivated, "addListener", (fn) => {
37+
onActivated = fn;
38+
});
39+
let resolveStorage;
40+
const storagePromise = new Promise((resolve, _) => resolveStorage = resolve);
41+
stub(chrome.storage.session, "get", () => storagePromise);
42+
tabRecency.init();
43+
// Here, chrome.tabs.onActivated listeners have been added by tabrecency, but the
44+
// chrome.storage.session data hasn't yet loaded.
45+
onActivated({ tabId: 5 });
46+
resolveStorage({});
47+
await tabRecency.init();
48+
assert.equal([5], tabRecency.getTabsByRecency());
49+
});
50+
3251
should("loadFromStorage handles empty values", async () => {
3352
stub(chrome.tabs, "query", () => Promise.resolve([{ id: 1 }]));
3453

3554
stub(chrome.storage.session, "get", () => Promise.resolve({}));
36-
await tabRecency.loadFromStorage();
55+
await tabRecency.init();
3756
assert.equal([], tabRecency.getTabsByRecency());
3857

3958
stub(chrome.storage.session, "get", () => Promise.resolve({ tabRecency: {} }));
4059
await tabRecency.loadFromStorage();
4160
assert.equal([], tabRecency.getTabsByRecency());
4261
});
4362

44-
should("loadFromStorage merges in-memory tabs with in-storage tabs", async () => {
63+
should("loadFromStorage works", async () => {
4564
const tabs = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
4665
stub(chrome.tabs, "query", () => Promise.resolve(tabs));
4766

48-
tabRecency.register(3);
49-
tabRecency.register(4);
50-
5167
const storage = { tabRecency: { 1: 5, 2: 6 } };
5268
stub(chrome.storage.session, "get", () => Promise.resolve(storage));
5369

5470
// Even though the in-storage tab counters are higher than the in-memory tabs, during
5571
// loading, the in-memory tab counters are adjusted to be the most recent.
56-
await tabRecency.loadFromStorage();
57-
58-
assert.equal([4, 3, 2, 1], tabRecency.getTabsByRecency());
59-
});
72+
await tabRecency.init();
6073

61-
should("loadFromStorage works if there are no in-memory tab tabs", async () => {
62-
const tabs = [{ id: 1 }, { id: 2 }];
63-
stub(chrome.tabs, "query", () => Promise.resolve(tabs));
64-
65-
const storage = { tabRecency: { 1: 4, 2: 1 } };
66-
stub(chrome.storage.session, "get", () => Promise.resolve(storage));
67-
68-
await tabRecency.loadFromStorage();
74+
assert.equal([2, 1], tabRecency.getTabsByRecency());
6975

70-
// This tab's counter should be the max of the tab counters loaded from storage.
71-
tabRecency.register(2);
76+
tabRecency.queueAction("register", (3));
77+
tabRecency.queueAction("register", (1));
7278

73-
assert.equal([2, 1], tabRecency.getTabsByRecency());
79+
assert.equal([1, 3, 2], tabRecency.getTabsByRecency());
7480
});
7581

7682
should("loadFromStorage prunes out tabs which are no longer active", async () => {
@@ -79,7 +85,7 @@ context("TabRecency", () => {
7985

8086
const storage = { tabRecency: { 1: 5, 2: 6 } };
8187
stub(chrome.storage.session, "get", () => Promise.resolve(storage));
82-
await tabRecency.loadFromStorage();
88+
await tabRecency.init();
8389
assert.equal([1], tabRecency.getTabsByRecency());
8490
});
8591
});

0 commit comments

Comments
 (0)