DEV Community

Emir Mrkaljević
Emir Mrkaljević

Posted on

Ask for one site at a time: optional host permissions and dynamic content scripts in MV3

ShrinkWatch EU is an open source extension that shows the price per kg or litre on 71 European supermarket web shops. Asking for all of them at install time would mean more than 180 site patterns in the permission warning, and a harder store review. So only six shops are enabled at install. Every other shop is switched on by the user, one at a time, from the popup.

This post is how that works in Manifest V3, and the three bugs I hit on the way.

The manifest

The six default shops go in host_permissions and a static content_scripts entry. Everything else goes in optional_host_permissions:

{
  "permissions": ["storage", "scripting", "activeTab"],
  "host_permissions": ["https://*.konzum.hr/*"],
  "optional_host_permissions": ["https://*.rewe.de/*", "https://*.lidl.fr/*"]
}
Enter fullscreen mode Exit fullscreen mode

activeTab covers a "Check this page now" button that works on any site, once, without a standing permission.

Asking for the permission

The request must run directly in the click handler, or the browser refuses to show the prompt:

enable.onclick = async () => {
  const ok = await chrome.permissions.request({ origins });
  if (!ok) return;
  await chrome.runtime.sendMessage({ type: 'sync' });
};
Enter fullscreen mode Exit fullscreen mode

Bug 1: asking for the wrong country. Several shops share one adapter (Kaufland DE, CZ and SK; Tesco IE and UK). My first version asked for the first domain in the list, so on tesco.ie the prompt asked for tesco.com, and the content script never ran. The fix: ask only for the origins of the domain in the open tab.

Registering the content script at runtime

A granted optional permission does nothing by itself. The background script registers one dynamic content script covering every granted shop:

const script = { id: 'optional-shops', matches, js: SCRIPTS, css: ['src/badge.css'], runAt: 'document_idle' };
if (exists) await chrome.scripting.updateContentScripts([script]);
else await chrome.scripting.registerContentScripts([script]);
Enter fullscreen mode Exit fullscreen mode

Firefox does not keep dynamic scripts across browser restarts, so this sync also runs on every start.

Bug 2: "Duplicate script ID". onInstalled, onStartup, permissions.onAdded and the popup's message often fire at the same moment. Each one started its own register or unregister call, and they raced. Some failed with "Duplicate script ID" or "Nonexistent script ID", and sometimes no script was registered at all. The fix is a tiny queue so only one sync runs at a time:

let queue = Promise.resolve();
function sync() {
  const run = queue.then(syncNow);
  queue = run.catch(e => console.warn('sync failed', e));
  return run;
}
Enter fullscreen mode Exit fullscreen mode

Each sync reads the current granted permissions and makes the registration match them, so running it twice is harmless.

Turning a shop off again

Bug 3: Disable did not really disable. After chrome.permissions.remove, clicking Enable again brought access back with no prompt. That is how Chrome works: a removed optional permission stays approved, so asking again is granted silently. To revoke it fully, the user has to use Site access on the extension's details page, and the popup now says so when removal fails.

There was a second part: a tab opened while access was being removed could still show a badge from a stale registration. So on opt-in shops the content script now asks the background whether access is still granted before it shows anything:

chrome.permissions.contains({ origins }).then(reply, () => reply(false));
Enter fullscreen mode Exit fullscreen mode

Was it worth it?

Yes. The install prompt lists six shops instead of 71, the extension never gets access to a site you don't shop on, and every piece above is covered by tests with a simulated chrome object, run with node --test.

This article was written with AI assistance. The code, tests and examples come from the repo.

Top comments (0)