Back
Lesson 8

Detecting Tab Changes

View Source Code

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 document or window)
  • Terminate when idle to save resources
FeaturePopup ScriptBackground Script
LifespanOnly while popup is openAlways running (when needed)
DOM AccessYesNo
Event ListenersLimited to popup lifecycleBrowser-wide events
Use CaseUser interaction, UI updatesEvent 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:

  1. Start when events occur
  2. Execute event handlers
  3. 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:

  1. Open extension popup on Page A, type a note
  2. Navigate to Page B (no note)
  3. Icon should show empty state
  4. Switch back to Page A
  5. 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:

  1. User is on github.com/repo1 with notes
  2. User clicks link to github.com/repo2 (no notes)
  3. 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:

ActionEvent TriggeredIcon Updates?
Switch tabsonActivated✅ Yes
Click link (same tab)onUpdated✅ Yes
Page title changesonUpdated❌ No (changeInfo.url is undefined)
Background tab loadsonUpdated❌ 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

  1. Open Page A, save note
  2. Open Page B in new tab (no note)
  3. Switch between tabs
  4. Expected: Icon changes between full (A) and empty (B)

Test Scenario 2: In-Tab Navigation

  1. On Page A with note
  2. Click link to Page B (same tab)
  3. Expected: Icon changes to empty when URL changes

Test Scenario 3: Note Creation

  1. On Page C (no note)
  2. Open popup, type note
  3. Close popup
  4. 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.