C.W.K.
Stream
Lesson 04 of 06 · published

Per-Tab vs Global Side Panels — Two Scoping Models

~12 min · side-panel, setOptions, per-tab, global, tabs

Level 0Extension Curious
0 XP0/54 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"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.url in 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: false for 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.onUpdated with status === '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:

  1. On install/startup, call global setOptions({ path: 'panel.html', enabled: true }).
  2. On every onActivated and every onUpdated(complete), compute the desired per-tab state from tab.url and call setOptions({ tabId, path, enabled }).
  3. 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.

Per-tab wins over global. Use global for the default "always on," use per-tab to specialize (filter, disable, swap path). Re-apply on both onActivated and onUpdated for race-free behavior.
Don't disable the panel without telling the user why. Silently hiding ClipDeck from the chooser on a particular site is confusing. If you must disable on, say, banking URLs, render a tiny popup or a one-time toast explaining: "ClipDeck is paused on this site for privacy." Otherwise the user assumes ClipDeck broke and uninstalls.

Code

background.js — global + per-tab side-panel configuration·javascript
// background.js — global default + per-tab specialization
const BLOCKLIST = [
  /^https:\/\/.*\.bank\.com\//i,
  /^https:\/\/accounts\.google\.com\//i,
];

async function configurePanelForTab(tab) {
  if (!tab?.id || !tab.url) return;
  const blocked = BLOCKLIST.some((re) => re.test(tab.url));
  if (blocked) {
    await chrome.sidePanel.setOptions({
      tabId: tab.id,
      enabled: false,
    });
    return;
  }
  const host = new URL(tab.url).host;
  await chrome.sidePanel.setOptions({
    tabId: tab.id,
    path: `panel.html?host=${encodeURIComponent(host)}`,
    enabled: true,
  });
}

chrome.runtime.onInstalled.addListener(async () => {
  // Global default — covers tabs that haven't had a per-tab call yet.
  await chrome.sidePanel.setOptions({ path: "panel.html", enabled: true });
  await chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: false });
});

chrome.tabs.onActivated.addListener(async ({ tabId }) => {
  const tab = await chrome.tabs.get(tabId);
  await configurePanelForTab(tab);
});

chrome.tabs.onUpdated.addListener(async (tabId, info, tab) => {
  if (info.status !== "complete") return;
  await configurePanelForTab(tab);
});
panel.js — host filter parsed from path query string·javascript
// panel.js — read the host filter from the URL on mount
async function getFilteredClips() {
  const params = new URLSearchParams(location.search);
  const host = params.get("host");
  const { clips = [] } = await chrome.storage.local.get("clips");
  if (!host) return clips;
  return clips.filter((c) => {
    try {
      return new URL(c.url).host === host;
    } catch {
      return false;
    }
  });
}

async function render() {
  const clips = await getFilteredClips();
  const params = new URLSearchParams(location.search);
  const host = params.get("host");
  document.getElementById("filterLabel").textContent = host
    ? `Showing clips from ${host}`
    : "All clips";
  // ...render `clips` into the DOM as before...
}

chrome.storage.onChanged.addListener((c, a) => a === "local" && "clips" in c && render());
render();

External links

Exercise

Replace clipdeck/background.js's panel registration with the first code block. Update clipdeck/panel.html to add <div id="filterLabel"></div> at the top of the body and update panel.js with the filter logic from the second code block. Reload. Save clips on three different sites (wikipedia, github, news.ycombinator.com). Switch between the tabs — the panel header should update to Showing clips from wikipedia.org, etc., and only the clips matching that host should render. Switch to a blocklisted URL (try the Chrome Web Store) — ClipDeck should disappear from the side-panel chooser. Switch back to a real page — it reappears.
Hint
If the panel does not refresh when you switch tabs, the path query string isn't actually changing — confirm configurePanelForTab runs on onActivated by adding a console.log('reconfiguring', tab.id, tab.url) line at the top. If the filter shows no clips even on the right site, your clip's url might have a different host than tab.url due to subdomains; loosen the filter to compare second-level domains if you want a smoother UX. The blocklisted-URL test: confirm the chooser entry vanishes, not the open panel — already-open panels do not auto-close on per-tab disable.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.