"Same panel, different contents — that's per-tab. Same panel, same contents, every tab — that's global. ClipDeck wants both at once, and the API lets you do it cleanly if you understand which call wins."
Two Scoping Layers
Every call to chrome.sidePanel.setOptions sets state at one of two scopes:
- Per-tab — call with
{ tabId: T, ... }. The settings (path, enabled) apply only when tab T is active. Each tab gets its own independent state. - Global — call without
tabId. The settings become the default for any tab that does not have an explicit per-tab override.
Per-tab wins over global. If tab T has setOptions({ tabId: T, enabled: false }) and the global says enabled: true, the panel is disabled on T and enabled everywhere else.
What ClipDeck Uses
ClipDeck has one clip library (global storage), so the panel content is fundamentally the same across tabs. But two per-tab behaviors matter:
- Filtered view by site. When the user is on github.com, the panel can pre-filter to show clips saved from github.com only. This works by reading
tab.urlin the SW's tab-update handler and writing a different query string into the panel path:panel.html?host=github.com. - Disabled on sensitive sites. On banking, password manager, or specific user-blocklisted URLs, set
enabled: falsefor that tab so the panel chooser hides ClipDeck. Acknowledges the user's privacy expectation.
The default (global) state remains enabled: true, path: 'panel.html' so the panel is available on every other tab without per-tab work.
How setOptions Composes
The path field can be different per tab — useful for the site-filtered view above. Chrome treats panel.html?host=github.com as a navigation; if your panel JS reads new URLSearchParams(location.search) on mount, it can filter the rendered list accordingly. The panel reloads when path changes.
The enabled field controls visibility in the chooser only — disabling the panel for a tab does not close an already-open panel on a different tab. Switching to a tab where the panel is disabled hides ClipDeck from the chooser; switching back re-exposes it.
The Activation Lifecycle
Tab events that should trigger a setOptions call:
chrome.tabs.onActivated— user switched to this tab.chrome.tabs.onUpdatedwithstatus === 'complete'— page finished loading; URL is now stable and reliable to read.chrome.tabs.onCreated— new tab opened, may want to inherit the global default explicitly.
You do NOT need to call setOptions on chrome.runtime.onInstalled for every existing tab — the global setOptions covers them. But you DO need the global call once so the panel has a sensible default for tabs you have not yet specifically handled.
The Race-Free Pattern
The combined onActivated + onUpdated pattern is race-free in practice:
- On install/startup, call global
setOptions({ path: 'panel.html', enabled: true }). - On every onActivated and every onUpdated(complete), compute the desired per-tab state from
tab.urland callsetOptions({ tabId, path, enabled }). - The first event for any tab "upgrades" the global default to the per-tab specific. Subsequent events idempotently re-apply.
Idempotency matters because both events can fire close together (open a new tab, navigate it, click another tab and back — the SW receives a fast sequence). Re-applying setOptions with the same values is cheap.