One Checkbox, Three Kinds of State in a Chrome MV3 Extension
I thought I had a settings bug. What I actually had was three different kinds of state pretending to be one boolean. While building a Chrome Manifest V3 email-tracker blocker, I expected a simple flow: you flip Gmail on in the settings, and the extension starts working in Gmail. That was the theory, anyway. The problem showed up when I was testing on a second Chrome profile. I'd enabled Gmail on my main profile, and Chrome Sync helpfully carried that preference over to the other one. But the optional permission for mail.google.com didn't come along - host grants live in the local profile and never sync. Profile number two now believed Gmail was enabled while lacking the host grant needed to inject the inbox content script or inspect its DOM. Depending on how you write your code, that's either a silent no-op or an extension quietly behaving as if access exists when it does not. Neither is great. Once I stopped and wrote it down, the picture got clearer. There are three separate things here: the inbox the user wants enabled, the host access Chrome has actually granted in this profile, and the dynamic DNR rules that are currently installed. Collapsing them into one flag is convenient. It's also wrong. The manifest is a menu, not an order The extension declares each webmail origin under optional_host_permissions . Every inbox gets activated on its own, and Chrome only asks the user for access when they turn that particular integration on. Here's the thing I had to internalize: declaring an optional origin means nothing by itself. Until the live grant exists, the extension has no business registering a content script for that inbox, poking at its DOM, or - by its own scoping policy - activating client-scoped blocking rules for it. Why bother with per-inbox prompts at all? Mostly trust. A tracker blocker that asks for all your webmail up front looks exactly like the thing it's supposed to protect you from. Asking for Gmail when you enable Gmail - and nothing more - is an easy story to tell users, and it doesn't hurt during store review either. Chrome's docs recommend optional permissions for exactly this kind of informed control. The docs don't mention the hard part, though: making the runtime actually preserve that choice through permission revocations, browser sync, and service worker restarts. That's on you. Packaged rules as templates The extension ships two packaged DNR rulesets, and both stay disabled. The background service worker loads the high-confidence pixel rules from rules/pixels.json as a read-only template and clones only those into extension-owned, client-scoped dynamic rules. Recognized tracking links are handled by the content script rather than navigation DNR. The scope I want is, conceptually: clients enabled in settings AND hosts granted in this profile Before generating anything, the extension asks chrome.permissions.contains() about the origin. A synced setting is intent. It is not evidence of a current host grant, so the extension does not activate that client. The generated blocking rules list only the enabled-and-granted webmail origins in initiatorDomains . If Gmail is the only authorized inbox, a request elsewhere on the web doesn't get blocked just because it happens to match a tracker pattern. The same transformation also emits higher-priority allow rules for tracker domains the user has explicitly whitelisted. Rebuild everything, don't patch My first instinct was incremental: checkbox on, add rules; checkbox off, remove them. It works right up until reality intervenes. A permission gets revoked from Chrome's own extension settings page. Settings arrive via sync. The service worker dies between two state changes. Several permission and settings events land on top of each other. An update leaves stale dynamic rules behind from the previous version. I hit most of these within a week of testing. So the extension doesn't track transitions anymore. On every relevant event it rebuilds the entire desired rule set from current state: disable the packaged static rulesets, read the extension-owned dynamic rules, check the current optional host grants, treat anything ungranted as disabled, build the full desired set of block and allow rules, compare normalized fingerprints, and replace the owned rules only if something actually changed. Run it twice with the same inputs and you get the same rules and no write. That idempotence is what makes the whole thing debuggable. Reserved rule-ID ranges keep this component from stepping on rules it doesn't own. Failing closed When something goes wrong, the extension errs toward doing less. If the permissions API is unavailable, every client is treated as inactive. If an individual permission check throws, that client is treated as inactive. If the packaged template can't be loaded while old extension-owned rules are still installed, those rules get removed before the error is reported. Yes, that can temporarily reduce protection. I went back and forth on this while testing - an argument can be made for keeping the last known-good rules around. But keeping behavior alive outside the scope the user explicitly granted felt worse than a gap. A tracker blocker that blocks things beyond that scope is a different kind of broken, and a scarier one. One reconciliation at a time Settings and permissions can change nearly simultaneously. A user enables an inbox, answers the Chrome prompt, edits the allow list, then disables the inbox again - all inside a few seconds. If each event kicks off its own independent reconciliation, an older snapshot can finish after a newer one and win. So reconciliations go through a queue. Each snapshot waits for the previous operation to settle. A failed operation doesn't poison the queue or stop later state from applying. Small detail. But in an MV3 service worker - event-driven, no permanent process - it's the difference between "eventually correct" and "correct until you click fast." "Detected" is not "blocked" One more boundary worth writing down: production extensions don't get a tidy callback for every DNR match. The inbox UI detects tracker resources present in the message DOM, while DNR separately blocks matching image and ping requests. Two different signals, two different mechanisms. So a detection shown in the UI must not be described as proof that a specific network request was blocked. The product copy and reports keep "detected" and "blocked" as separate words on purpose. Where the data lives Detection history and sender statistics sit in local IndexedDB. Attribution uses sender metadata, opaque identifiers from the webmail provider, and the relevant image or link URLs. Subject and body text aren't extracted, stored, or transmitted - with one honest asterisk: on Outlook, Superhuman, and Yahoo, the locally parsed accessibility label that carries the sender can also contain a subject line. I'd rather document that than pretend the wording is simpler than it is. Preferences may sync through Chrome; host grants stay local, as established. Paid operations and opt-in crash reporting are separate network paths, not part of inbox detection. Proton Mail deserves its own caveat too. Its image proxy is on by default, so browser-level DNR pixel blocking is constrained there. Sender attribution and tracking-link protection can still work, but I don't claim identical pixel coverage across clients. The tests that earned their keep The useful test matrix turned out to be all about state transitions: a fresh install produces no client-scoped rules; enabling Gmail creates rules only once its host grant exists; a synced Gmail preference without the local grant creates nothing; revoking a grant removes that client's initiator domain; disabling one client leaves the others intact; an allow-listed tracker domain gets its higher-priority allow rule; a failed permission check disables the affected client; a template-loading failure clears stale owned rules; repeated reconciliation with unchanged state performs no update; and rapid snapshots apply in order. That last one surprised me most. Before the queue existed, I could reliably make the older state win just by toggling a checkbox twice quickly - the first reconciliation, carrying stale state, finished second and overwrote the fresh one. Watching a disabled inbox come back to life because I clicked fast was the moment the queue stopped being optional. The lesson I keep coming back to: product settings, browser authority, and runtime rules are different kinds of state. Once I stopped mashing them into one flag, the permission model became something I could explain, test, and audit without hand-waving. How do other extension teams handle this? Do you rebuild the full desired dynamic-rule set from scratch, or apply incremental mutations across permission and settings events? I'd like to hear where incremental has actually held up. Implementation references: Disclosure: I used AI to help structure an early research outline. I rewrote this article from my own implementation experience and verified the technical claims against the implementation and Chrome's documentation. Top comments (0)
Comments
No comments yet. Start the discussion.