Overview
Implement background script logic to detect tab changes and URL navigation, automatically updating the extension icon when users switch between pages. This lesson covers service workers, tab event listeners, and coordinating background scripts with popup logic.
By the end, TabNotes will display the correct icon state whenever users change tabs or navigate to new pages.
What You’ll Learn
- Understand background scripts and service workers in Manifest V3
- Listen for tab activation events (
tabs.onActivated) - Detect URL changes within tabs (
tabs.onUpdated) - Coordinate background scripts with popup logic
- Share utility functions across extension components
- Handle edge cases (new tabs, closed tabs, URL fragments)
What Are Background Scripts?
Background scripts (called service workers in Manifest V3) are JavaScript code that runs in the background, independent of any visible UI. They:
- Listen for browser events (tab changes, network requests, alarms)
- Persist across popup open/close cycles
- Can’t directly access DOM (no
documentorwindow) - Terminate when idle to save resources
Popup vs Background Script
| Feature | Popup Script | Background Script |
|---|---|---|
| Lifespan | Only while popup is open | Always running (when needed) |
| DOM Access | Yes | No |
| Event Listeners | Limited to popup lifecycle | Browser-wide events |
| Use Case | User interaction, UI updates | Event monitoring, coordination |
For TabNotes: Background script monitors tab changes, popup handles user input.
Understanding Service Workers
Manifest V3 replaced persistent background pages with service workers - event-driven scripts that:
- Start when events occur
- Execute event handlers
- Terminate after inactivity (~30 seconds)
Key Difference from Manifest V2:
- V2: Persistent background page (always running)
- V3: Event-driven service worker (starts/stops as needed)
Why the change? Resource efficiency - service workers consume zero resources when idle.
Service Worker Lifecycle
Event Occurs → Worker Starts → Handler Executes → Worker Idle → Worker Terminates
Don’t rely on global state persisting between events - use chrome.storage instead.
Tab Activation Events
The tabs.onActivated event fires when users switch to a different tab:
// entrypoints/background.ts
export default defineBackground(() => {
browser.tabs.onActivated.addListener(async (activeInfo) => {
const tabId = activeInfo.tabId;
console.log('User switched to tab:', tabId);
});
});
activeInfo Object:
{
tabId: 123, // ID of newly active tab
windowId: 1 // ID of window containing tab
}
When This Fires:
- User clicks a different tab
- Keyboard shortcut switches tabs (Ctrl+Tab)
- API call activates a tab programmatically
When This Doesn’t Fire:
- User types in current tab
- Page content changes
- URL changes within same tab (requires
tabs.onUpdated)
Implementing Tab Change Detection
Update the background script to change icons on tab activation:
// entrypoints/background.ts
import changeIcon from '@/utils/changeIcon';
export default defineBackground(() => {
browser.tabs.onActivated.addListener(async (activeInfo) => {
await changeIcon(activeInfo.tabId);
});
});
Testing:
- Open extension popup on Page A, type a note
- Navigate to Page B (no note)
- Icon should show empty state
- Switch back to Page A
- Icon should show full state
Problem Solved: Icon now reflects current tab’s note state automatically.
Detecting URL Changes
Tab activation handles tab switches, but what about navigation within the same tab?
Scenario:
- User is on
github.com/repo1with notes - User clicks link to
github.com/repo2(no notes) - Tab doesn’t change, but URL does
tabs.onActivated doesn’t fire. We need tabs.onUpdated.
The tabs.onUpdated Event
Fires when tab properties change:
browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
console.log('Tab updated:', tabId);
console.log('What changed:', changeInfo);
console.log('Full tab object:', tab);
});
changeInfo Object:
{
url: 'https://github.com/repo2', // Only present if URL changed
status: 'complete', // 'loading' or 'complete'
title: 'Repo 2' // Only present if title changed
}
Many False Positives: onUpdated fires for status changes, title changes, favicon changes - not just URL changes.
Filtering for URL Changes Only
Only update icon when URL actually changes:
// entrypoints/background.ts
import changeIcon from '@/utils/changeIcon';
export default defineBackground(() => {
// Tab switched
browser.tabs.onActivated.addListener(async (activeInfo) => {
await changeIcon(activeInfo.tabId);
});
// URL changed within tab
browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
// Only process if URL changed AND tab is active
if (changeInfo.url && tab.active) {
changeIcon(tabId);
}
});
});
Why check tab.active?
Without it, icons update for background tabs too (unnecessary work). We only care about the visible tab’s icon.
Complete Background Script
Here’s the full implementation with both event listeners:
// entrypoints/background.ts
import changeIcon from '@/utils/changeIcon';
export default defineBackground(() => {
// Handle tab switches
browser.tabs.onActivated.addListener(async (activeInfo) => {
await changeIcon(activeInfo.tabId);
});
// Handle URL changes within tabs
browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
if (changeInfo.url && tab.active) {
changeIcon(tabId);
}
});
});
Testing Scenarios:
| Action | Event Triggered | Icon Updates? |
|---|---|---|
| Switch tabs | onActivated | ✅ Yes |
| Click link (same tab) | onUpdated | ✅ Yes |
| Page title changes | onUpdated | ❌ No (changeInfo.url is undefined) |
| Background tab loads | onUpdated | ❌ No (tab.active is false) |
Sharing Code Between Components
Both popup and background need changeIcon() - how do we share it?
Creating a Utils Directory
// utils/changeIcon.ts
import { storage } from 'wxt/storage';
import getPageKey from './getPageKey';
const changeIcon = async (tabId: number) => {
const tab = await browser.tabs.get(tabId);
if (!tab.url) return;
const pageKey = getPageKey(tab.url);
const notesStorage = await storage.getItem('sync:notesStorage');
const noteData = notesStorage?.[pageKey];
if (noteData && noteData.note) {
await browser.action.setIcon({
tabId,
path: {
"16": "/icon-full/16.png",
"32": "/icon-full/32.png",
"48": "/icon-full/48.png",
"96": "/icon-full/96.png",
"128": "/icon-full/128.png"
}
});
} else {
await browser.action.setIcon({
tabId,
path: {
"16": "/icon/16.png",
"32": "/icon/32.png",
"48": "/icon/48.png",
"96": "/icon/96.png",
"128": "/icon/128.png"
}
});
}
};
export default changeIcon;
Importing in Multiple Files
// entrypoints/background.ts
import changeIcon from '@/utils/changeIcon';
// entrypoints/popup/main.ts
import changeIcon from '@/utils/changeIcon';
The @/ Alias: WXT configures TypeScript to treat @/ as the project root, making imports clean and relative-path-free.
Edge Cases and Gotchas
Chrome:// Pages
Issue: Extension can’t access chrome:// pages (settings, extensions, etc.)
Solution: changeIcon() already handles this with if (!tab.url) return;
const tab = await browser.tabs.get(tabId);
if (!tab.url) return; // Exits for chrome:// pages
Hash Changes Don’t Fire onUpdated
Scenario: User navigates from example.com#section1 to example.com#section2
Result: tabs.onUpdated doesn’t fire (hash changes don’t count as navigation)
Impact: Icon doesn’t update if getPageKey() excludes hash (which we do). This is acceptable - same page, same note.
New Tabs
When users open a new tab, onActivated fires with the new tab’s ID. The changeIcon() call handles it gracefully (new tabs have no notes → empty icon).
Performance Considerations
Event Frequency
tabs.onUpdated fires very frequently:
- Page load start
- Page load complete
- Title change
- Favicon change
- URL change
Optimization: Filter for changeInfo.url && tab.active to minimize unnecessary work.
Storage Reads
Every changeIcon() call reads from storage:
const notesStorage = await storage.getItem('sync:notesStorage');
For TabNotes: Acceptable - storage reads are fast and sync storage is limited to 100KB.
For Large Extensions: Consider caching notesStorage in memory and updating on storage changes.
Testing the Complete Flow
You can test our project with these scenarios:
Test Scenario 1: Tab Switching
- Open Page A, save note
- Open Page B in new tab (no note)
- Switch between tabs
- Expected: Icon changes between full (A) and empty (B)
Test Scenario 2: In-Tab Navigation
- On Page A with note
- Click link to Page B (same tab)
- Expected: Icon changes to empty when URL changes
Test Scenario 3: Note Creation
- On Page C (no note)
- Open popup, type note
- Close popup
- Expected: Icon immediately shows full state
All scenarios should work seamlessly.
What’s Next
TabNotes now automatically updates icons when users switch tabs or navigate to new pages. The extension provides real-time visual feedback about note status.
Lesson 9 introduces content scripts - injecting code directly into web pages to display a visual ribbon indicator when pages have notes, further enhancing user awareness.