3

To remove the global variables used in a MV2 background script when migrating to a MV3 service worker, all the guides I've found have just given an example of replacing a single global variable with a few lines of setting and then getting using chrome.storage, but it's still not clear to me how it can be used in a bit more complicated scenario.

For instance:

const activatedTabs = [];
let lastActiveTabInfo;
chrome.tabs.onActivated.addListener((activeInfo) => {
  if (activatedTabs.length === 0) {
    activatedTabs.push(activeInfo.tabId);
    lastActiveTabInfo = activeInfo;
  }
}

How could the snippet above be refactored to use chrome.storage and remove the global variables?

aderchox
  • 3,163
  • 2
  • 28
  • 37
  • A bit off topic but just curious, what's the reason to push to `activatedTabs` only if it's empty? Doesn't it only keep track of the first activated tab? – pkacprzak Sep 21 '22 at 11:16

1 Answers1

9

The number of variables in the state doesn't change the approach:

  1. read the state on the start of the script
  2. save the state on change

For small data (1MB total) use chrome.storage.session, which is in-memory i.e. it doesn't write to disk, otherwise use chrome.storage.local. Both can only store JSON-compatible types i.e. string, number, boolean, null, arrays/objects of such types. There's also IndexedDB for Blob or Uint8Array.

let activatedTabs;
let lastActiveTabInfo;
let busy = chrome.storage.session.get().then(data => {
  activatedTabs = data.activatedTabs || [];
  lastActiveTabInfo = data.lastActiveTabInfo;
  busy = null;
});
const saveState = () => chrome.storage.session.set({
  activatedTabs,
  lastActiveTabInfo,
});

chrome.tabs.onActivated.addListener(async info => {
  if (!activatedTabs.length) {
    if (busy) await busy;
    activatedTabs.push(info.tabId);
    lastActiveTabInfo = info;
    await saveState();
  }
});

You can also maintain a single object with properties instead:

const state = {
  activatedTabs: [],
  lastActiveTabInfo: null,
};
const saveState = () => chrome.storage.session.set({ state });
let busy = chrome.storage.session.get('state').then(data => {
  Object.assign(state, data.state);
  busy = null;
});

chrome.tabs.onActivated.addListener(async info => {
  if (!state.activatedTabs.length) {
    if (busy) await busy;
    state.activatedTabs.push(info.tabId);
    state.lastActiveTabInfo = info;
    await saveState();
  }
});

Warning! onMessage listener can't be async, see https://stackoverflow.com/a/53024910.

Note that if you subscribe to frequent events like tabs.onActivated, your service worker may restart hundreds of times a day, which wastes much more resources than keeping an idle persistent background page. The Chromium team ignores this problem, but you shouldn't, and luckily there's a way to reduce the number of restarts by prolonging the SW lifetime. You still need to read/save the state as shown.

wOxxOm
  • 65,848
  • 11
  • 132
  • 136
  • The way that the busy function works is really smart and beautiful. But with regards to the note "...wastes much more resources than keeping an idle persistent background page.", I agree, but persistent background pages are no more allowed in MV2, that's why I tried to re-write the above snippet for use in service worker. The link on prolonging SW lifetime is great though, sounds pretty useful in my case as you've said, it just seems a bit advanced I don't know which part exactly relates to my case, but I'll read it once again to see if I can realize which parts I can use. Thanks a lot. – aderchox Jul 23 '22 at 13:03
  • Why did you put the code for `storage.session.get()` outside of `tabs.onActivated` handler and not just have it inside it? Isn't `storage.session.get()` going to finish after `tabs.onActivated` handler starts to execute anyway? A tab is activated, service worker runs, ``storage.session.get()`` executes, but control is passed to `tabs.onActivated` handler before it finishes, so handler waits anyway for it. Am I missing something? – Wizard Aug 07 '22 at 19:05
  • 1
    Because there can be multiple handlers. Also, the next event may occur while the script is already running so there's no need to await again. – wOxxOm Aug 07 '22 at 20:42
  • OK, now I understand, thanks. Just for clarification: since the `storage.session.get()` is asynchronous, `tabs.onActivated` handler ALWAYS starts before `storage.session.get()` resolves, am I correct? I mean, when worker starts, all synchronous code is executed before the handler that has woken him, but any asynchronous code resolves after the handler finishes (as long as the handler doens't have itself any asynchronous code). If handler has asynchronous code, `storage.session.get()` could finish before the handler finishes, right? – Wizard Aug 08 '22 at 12:51
  • 1
    When the script wakes up, the storage request is issued before the handler starts, then the handler starts, then the handler waits for the promise until the storage is actually read. When the script was already running there are no delays because the storage has been already read and `busy` is null. – wOxxOm Aug 08 '22 at 13:38
  • Thanks again. After some testing I did, I figured out everything. Your code is just beautiful, I wish there were more similar examples like yours for us to learn proper handling. – Wizard Aug 08 '22 at 19:50
  • If you can tolerate me, I have one last question. In your handler the last line is `await saveState();`. If I remove the `await`, does Chrome think that the handler is over immediately and starts the 30 seconds for the worker to terminate, even though `saveState()` is running? Is `await` necessary there? – Wizard Aug 08 '22 at 19:56
  • It doesn't influence SW lifetime or the handler's behavior in any way. It's not even necessary in this example and generally isn't. It's there to remind that this function is asynchronous. – wOxxOm Aug 08 '22 at 20:24