A learning walkthrough

Building a Chrome extension, one honest layer at a time.

Eighteen parts through a real Manifest V3 extension written in TypeScript with React. We start from the manifest, descend into the service worker, the popup, the Chrome APIs, then rise back up through the architecture, the distributed sync model, the custom algorithms, and the testing harness. Read each part next to the file it cites — the tutorial is the map, the source is the territory.

Start here

Reading order — a guided path through the repo

Don't read the source top-to-bottom. Open these files in order, in split panes, and follow the tutorial alongside. Each file is a single idea; the chain builds the whole picture.

  1. manifest.json — the one file Chrome reads first. Note background.service_worker, action.default_popup, and the permissions list. (Part 01)
  2. src/popup.html — the tiny shell loaded by the toolbar icon. It mounts one <div id="root"> and loads popup.js.
  3. src/popup.tsx — read initializeApp(). PING/PONG handshake before the React app is allowed to mount. (Part 09 walkthrough)
  4. src/components/App.tsx — read only the first useEffect (lines ~72–228). It's the popup-side of the handshake plus the message-proxy managers. (Part 09 walkthrough)
  5. src/background.ts — the service worker entry. Read the first 75 lines: managers declared, ensureInitialized, initializeManagers. (Part 05, 08 walkthroughs)
  6. src/background.ts (continued) — scroll to chrome.alarms.onAlarm and chrome.runtime.onConnect. Those are the two entry points that woke you up. (Part 05, 09)
  7. src/listeners/tabGroupListeners.ts — read processGroupQueue. This is the event-queue pattern used everywhere. (Part 06 walkthrough)
  8. src/lib/tabGroupManager.ts — read getGroup() (top of file). Fallback plus nullable return. (Part 07 walkthrough)
  9. src/lib/sync/syncEngine.ts — read processSyncQueue (around line 44). The drain loop with quota-aware backoff. (Part 12 walkthrough)
  10. src/lib/utils/groupNameResolver.ts — 50 lines; three input cases, one of which returns null. Property-test origin story. (Part 12)
  11. src/lib/bookmarks/bookmarkManager.ts — glance at the public method surface. This is what the SyncEngine calls into.
  12. src/lib/storage/storageManager.ts — see how a manager holds in-memory state and persists bounded slices to chrome.storage.sync. (Part 10)
  13. src/lib/utils/logger.ts — the subject:verb:outcome event naming you'll see everywhere. (Part 14)
  14. tests/unit/groupNameResolver.test.ts — a 20-line Vitest file. See how chrome.* mocking is avoided entirely for pure utils. (Part 13)
  15. tests/property/sync/ and tests/e2e/ — skim one of each. Three test layers, three different truths. (Part 13)
  16. src/lib/utils/pathMapper.ts — pure functions for bidirectional URL prefix rewriting. No state, no Chrome APIs. (Part 16, 17)
  17. src/components/SearchBar.tsx — fuzzy search across tabs + bookmarks with sync-status chips. (Part 18)
Note
The six files marked "walkthrough" get a line-by-line annotation later in the tutorial. When you hit one, open that source file in a split pane — the numbered circles in the code match numbered notes below.
Part 01 — Foundations

Anatomy of a Manifest V3 extension

A Chrome extension is a small web app Chrome lets run in privileged contexts. Everything starts from one declaration file: manifest.json.

{
  "manifest_version": 3,
  "name": "Tab Group Sync",
  "version": "1.2.0",
  "permissions": ["tabs", "tabGroups", "bookmarks", "storage", "unlimitedStorage", "alarms"],
  "action":     { "default_popup": "popup.html" },
  "background": { "service_worker": "background.js", "type": "module" },
  "icons":      { "16": "icons/icon16.png", "48": "icons/icon48.png", "128": "icons/icon128.png" }
}
manifest.json — the extension's front door
  • manifest_version 3. The current model. MV2 is retired. The biggest MV3 change is replacing the persistent background page with an ephemeral service worker. That one word rewires the rest of the architecture — see Part 5.
  • permissions. Each string unlocks a Chrome API. tabGroups, bookmarks, alarms map 1:1 to chrome.tabGroups.*, chrome.bookmarks.*, chrome.alarms.*.
  • action.default_popup. The toolbar button opens this HTML as a small window — the popup.
  • background.service_worker. Runs outside any tab, can listen to global browser events, is terminated by Chrome when idle.

The three contexts

┌─────────────────────────┐ ┌──────────────────────────┐ │ Popup (React app) │◀────▶│ Service Worker (bg) │ │ • popup.html + popup.js│ port │ • background.js │ │ • lives while open │ │ • Chrome events, sync │ └─────────────────────────┘ └──────────┬───────────────┘ │ ▼ chrome.tabGroups / chrome.bookmarks / chrome.storage

There's a third context — content scripts that inject into web pages. This extension doesn't use any; it only manipulates Chrome-level state (groups, bookmarks, storage). You'll build simpler mental models if you ignore content scripts until you need them.

Key idea
Almost every extension bug, eventually, comes back to confusion about which context a line of code is running in, or the fact that the service worker was quietly terminated between two events.
Try this
Visit chrome://extensions, enable Developer mode, click Load unpacked, pick dist/. Pin the icon — that's the popup. Click "service worker" under the extension card — that's the background's DevTools console.
Part 02 — Foundations

Project scaffold and the dual build

An extension has two programs running in different environments: the popup (a small web page) and the service worker (a background process). They have different constraints, so they need separate builds.

The constraint that forces two builds

Chrome's Manifest V3 requires the service worker to be one self-contained JavaScript file. It cannot use import statements at runtime — every dependency must be bundled inline. The popup, on the other hand, is a normal web page that can split code into multiple files (chunks) for faster loading.

Since one build target needs inlining and the other needs code-splitting, we can't build both in a single pass. Hence: two Vite passes.

What each build step produces

npm run build │ ├── Step 1: tsc (typecheck only — catches errors, produces nothing) │ ├── Step 2: vite build (Pass 1 — the popup) │ Input: src/popup.tsx ← React app entry point │ Output: dist/popup.js ← main bundle │ dist/assets/popup.css ← extracted styles │ Config: emptyOutDir = true ← cleans dist/ first │ code-splitting ON ← can use multiple files │ ├── Step 3: BUILD_BACKGROUND=true (Pass 2 — the service worker) │ vite build │ Input: src/background.ts ← service worker entry point │ Output: dist/background.js ← ONE file, everything inlined │ Config: emptyOutDir = false ← keeps popup output from Step 2 │ inlineDynamicImports ← forces single-file output │ └── Step 4: copy-extension-files.js (copies manifest.json, icons, HTML, opener.html)

How Vite knows which pass to run

One config file, one environment variable. When BUILD_BACKGROUND is not set (Step 2), Vite builds the popup. When it's 'true' (Step 3), Vite builds the service worker. The key difference is inlineDynamicImports — it forces Vite to collapse all import statements into a single output file.

// vite.config.ts — the switch that controls both passes
const buildBackground = process.env.BUILD_BACKGROUND === 'true';

export default defineConfig({
  plugins: [react()],
  build: {
    outDir: 'dist',
    emptyOutDir: !buildBackground,             // ① Pass 1 cleans dist/, Pass 2 keeps it
    rollupOptions: {
      input: buildBackground
        ? { background: resolve(__dirname, 'src/background.ts') }   // ② Pass 2 entry
        : { popup:      resolve(__dirname, 'src/popup.tsx') },      // ③ Pass 1 entry
      output: {
        inlineDynamicImports: buildBackground  // ④ Pass 2 → single file; Pass 1 → chunks OK
      },
    },
    target: 'chrome112',
  },
});
  1. emptyOutDir. Pass 1 sets this to true — it wipes dist/ clean before writing popup files. Pass 2 sets it to false — it adds background.js alongside the popup output without deleting it.
  2. Pass 2 entry. src/background.ts imports everything it needs: StorageManager, BookmarkManager, SyncEngine, listeners, PathMapper. Vite follows every import and inlines them all into one file.
  3. Pass 1 entry. src/popup.tsx is the React app. Vite builds it normally with code-splitting — React, MUI, and app code may end up in separate chunks under dist/assets/.
  4. inlineDynamicImports. This is the critical flag. Without it, Vite would produce background.js plus separate chunk files — and Chrome would reject the service worker because it can't resolve import './chunk-abc123.js' at runtime.
# package.json — the full build command
"build": "tsc && vite build && BUILD_BACKGROUND=true vite build && node scripts/copy-extension-files.js"

What dist/ looks like after build

dist/
├── popup.js              ← React app (popup UI)         ~920 KB
├── popup.html            ← shell that loads popup.js
├── background.js         ← service worker (ALL code)    ~124 KB, fully self-contained
├── manifest.json         ← tells Chrome where to find popup.html and background.js
├── opener.html           ← fallback page for file:// URLs
├── welcome.html          ← first-install welcome page
├── assets/
│   └── popup.css         ← extracted MUI styles
└── icons/
    ├── icon16.png
    ├── icon48.png
    └── icon128.png

Notice: background.js has no companion chunks. It's one file containing the StorageManager, BookmarkManager, SyncEngine, PathMapper, all listeners, and all utilities — inlined by Vite. The popup, by contrast, references assets/popup.css separately.

TypeScript and the Chrome types

The @types/chrome package gives the Chrome APIs their type signatures, so chrome.tabGroups.query({}) autocompletes and returns a typed Promise<chrome.tabGroups.TabGroup[]>. This is one of the largest productivity wins you get from TypeScript in extension work — the Chrome API surface is enormous and documented primarily through clicks in developer.chrome.com.

Why two passes instead of two Vite configs?
You could create vite.config.popup.ts and vite.config.background.ts. The single-config-with-env-var approach is simpler for this project — one file to maintain, one npm run build command. For a larger extension with content scripts or multiple entry points, separate configs may be cleaner.
Try this
Run npm run build, then inspect dist/. Open background.js and search for class StorageManager — you'll find it inlined around line 800. Now open popup.js — it's a React bundle that doesn't contain StorageManager, because the popup uses message-proxy objects instead (see Part 09).
Part 03 — Foundations

Code organization

Good layout tells you where to put new code without thinking. This project separates bridges (listeners), domain logic (managers), UI (components), and configuration (specs + manifest). Dependencies flow one direction.

src/
├── background.ts              # service worker entry — composition root
├── popup.tsx                  # popup entry — React bootstrap
├── listeners/                 # bridge: Chrome events → manager methods
│   ├── tabGroupListeners.ts
│   ├── tabListeners.ts
│   └── bookmarkListeners.ts
├── lib/                       # domain logic (no React, no DOM)
│   ├── storage/storageManager.ts
│   ├── bookmarks/             # bookmarkManager, bookmarkQueries/Mutations, snapshotManager
│   ├── sync/syncEngine.ts     # orchestrator
│   ├── tabGroupManager.ts
│   ├── types/                 # shared TypeScript types
│   └── utils/                 # logger, errors, validators, name resolver, windowLabelBuilder
└── components/                # React UI
    ├── App.tsx                # root; owns manager instances
    ├── GroupList.tsx  GroupSection.tsx
    ├── Settings.tsx  FolderPicker.tsx
    ├── MoveGroupDialog.tsx  SnapshotList.tsx
    └── ErrorBoundary.tsx

tests/
├── unit/        # Vitest, mocked chrome APIs
├── property/    # fast-check invariants
└── e2e/         # Playwright, real Chrome with extension loaded

.kiro/
├── specs/       # requirements.md, design.md, tasks.md per feature
└── steering/    # product / tech / structure guidance

Dependency direction

Chrome events ──▶ listeners/ ──▶ lib/ (managers) ──▶ chrome.* APIs ▲ │ components/ ─┘ (App.tsx owns manager instances; children receive them as props)

UI components depend on managers. Listeners depend on managers. Managers depend on each other (with one explicit circular reference patched at startup — Part 8). No manager imports React, the DOM, or a listener. That asymmetry is what lets us unit-test managers in Node with a mocked chrome global.

Specs as code

The .kiro/specs/ directory is the system of record. Each feature has requirements.md (what), design.md (how), and tasks.md (a checklist). Before writing code, the contract goes there. If scope changes, the spec changes first. This repo has four specs: tab-group-sync (core), sw-reliability, move-group-across-windows, and observability-openobserve.

Convention
When you can't decide where a new piece of code goes, ask: does it touch React? → components. Does it touch chrome.* events only? → listeners. Pure business logic? → lib. If three pieces live in different places, the bridge between them usually belongs in a manager.
Part 04 — Foundations

UI frameworks: React (and a look at Svelte)

The popup is a React app. If you've only done backend work, React's mental model can feel backwards. This is the minimum you need to read this codebase fluently — plus a sketch of how Svelte would solve the same problem differently.

Four React concepts

1. Components are functions that return JSX

export default function Header({ title }: { title: string }) {
  return <h1>{title}</h1>;
}

JSX is syntactic sugar: <h1>...</h1> compiles to React.createElement('h1', ...). A component is just a function that React calls to get a description of what the DOM should look like.

2. useState gives a component memory

const [expanded, setExpanded] = React.useState(true);
// later:
<button onClick={() => setExpanded(!expanded)}>Toggle</button>

Calling setExpanded schedules a re-render. React re-invokes the component function, sees the new JSX, and updates the minimum DOM needed. You never write element.textContent = ... — you declare what the UI should look like for a given state.

3. useEffect runs side effects after render

React.useEffect(() => {
  const port = chrome.runtime.connect({ name: 'popup' });
  port.postMessage({ type: 'PING' });
  return () => port.disconnect();    // cleanup on unmount or before next run
}, []);   // [] = run once on mount

The second argument controls when the effect re-runs: [] means once; [x, y] means whenever x or y changes; omitted means every render (rare).

4. Props are function arguments, typed as interfaces

interface GroupSectionProps {
  title: string;
  groups: GroupViewModel[];
  storage: StorageManager;
  onToggleSync: (name: string) => void;     // callback passed from parent
  readOnly?: boolean;                        // ? = optional
}

export default function GroupSection(props: GroupSectionProps) { /* ... */ }

Data flows down (parent → child via props). Events flow up (child → parent via callback props). This is the unidirectional data flow you'll hear about. It makes the app easier to reason about than two-way data binding.

Svelte, as a comparison

Svelte reaches the same UI-rebuilding-from-state outcome with different ergonomics — compile-time reactivity instead of runtime diffing:

<script lang="ts">
  let expanded = true;     // reactive "cell" — just a variable
  $: label = expanded ? 'Collapse' : 'Expand';   // $: = recompute on deps change
</script>

<button on:click={() => expanded = !expanded}>{label}</button>
ReactSvelte
ReactivityRuntime diff via virtual DOMCompile-time, mutates DOM directly
StateuseState hook returns pairTop-level let
EffectsuseEffect(fn, deps)$: label & onMount
Bundle sizeLarger (React runtime + MUI)Smaller (compiled away)
EcosystemEnormous component libsSmaller, younger

This project chose React for library ecosystem (MUI gives us switches, dialogs, tree views that look native inside Chrome) and for familiarity. Svelte would be a fine choice for a simpler popup; the mental model changes (fewer hooks, more plain variables) but the Chrome-side code in this tutorial is identical either way.

Key idea
UI frameworks solve one problem: keep the DOM in sync with state, without forcing you to imperatively move bytes. Once you understand that, the specific syntax is a detail.
Part 05 — Runtime

The service worker — a background that forgets

The service worker is where your extension thinks. It also dies unpredictably. Those two facts produce most of the interesting code in src/background.ts.

Chrome terminates the worker after ~30 s of no events. When a new event fires, Chrome spins a fresh worker, re-runs background.js top-to-bottom, then dispatches the event. In-memory state is gone. This is the hardest mental shift from MV2.

Walkthrough: ensureInitialized

Every listener in background.ts calls this before touching a manager. Four responsibilities in twelve lines — the hot-path short-circuit, concurrency guard, failure containment, and retry reset.

// src/background.ts
let isReady = false;
let initPromise: Promise<boolean> | null = null;

async function ensureInitialized(): Promise<boolean> {
  if (isReady) return true;                                      // ①
  if (initPromise) return initPromise;                           // ②
  initPromise = (async () => {                                   // ③
    try {
      await initializeManagers();
      return true;
    } catch (error) {
      logger.error('ensureInitialized:failed', { error: /*…*/ });
      return false;                                              // ④
    }
  })().then(result => { initPromise = null; return result; });   // ⑤
  return initPromise;
}
  1. Hot path. Once initializeManagers() has set isReady = true, every subsequent call is a single boolean read. Zero cost, no microtask. This matters because every message handler starts with await ensureInitialized().
  2. Concurrency guard. If two events fire on a fresh worker at the same moment, the second caller sees initPromise already exists and awaits it. Without this line you'd double-init managers and double-register listeners.
  3. IIFE wrapped in a shared promise. We start the async work immediately and assign the resulting promise to a module-level variable before any await. That's what makes step ② observable to concurrent callers.
  4. Failure returns false, not throws. Handlers then do if (!await ensureInitialized()) return — a no-op is safer than a crash that might poison the event listener for the rest of the worker's life.
  5. Clear on settle. Whether init succeeded or failed, initPromise is reset to null. If it failed, the next event will retry from scratch. If it succeeded, isReady is now true so step ① wins anyway.

chrome.alarms, not setInterval

setInterval is dead weight in MV3 — the worker dies and takes it with it. Use chrome.alarms: Chrome persists them and fires on your worker, waking it if needed.

await chrome.alarms.create('periodic-sync', { periodInMinutes: 5 });

chrome.alarms.onAlarm.addListener(async (alarm) => {
  if (alarm.name !== 'periodic-sync') return;
  if (!await ensureInitialized()) return;        // wake + init if cold
  await syncEngine.syncAll();
});
Rule of thumb
Every listener in the service worker should assume "I might be the first thing running in a fresh worker." That's why ensureInitialized() is called at the top of each handler.
Part 06 — Runtime

Listening to browser events

An extension is mostly a bag of event listeners. Chrome fires events, your code reacts. Below is the pattern used throughout src/listeners/.

Walkthrough: processGroupQueue

When Chrome restores a window, onCreated fires many times within milliseconds. Parallel handlers would race on the same bookmark folder. The queue serializes with a gap between items — backpressure in a handful of lines.

// src/listeners/tabGroupListeners.ts
export function initializeTabGroupListeners(tabGroupManager: TabGroupManager): void {
  let groupQueue: chrome.tabGroups.TabGroup[] = [];              // ①
  let processingQueue = false;

  async function processGroupQueue() {
    if (processingQueue || groupQueue.length === 0) return;      // ②
    processingQueue = true;
    try {
      const group = groupQueue.shift()!;                         // ③
      await tabGroupManager.handleGroupVisible(group);
    } catch (error) {
      logger.error('tabGroup:queue:failed', { /*…*/ });          // ④
    } finally {
      processingQueue = false;
      if (groupQueue.length > 0) {
        setTimeout(processGroupQueue, 1000);                     // ⑤
      }
    }
  }

  chrome.tabGroups.onCreated.addListener((group) => {
    groupQueue.push(group);
    processGroupQueue();                                         // ⑥ fire-and-forget
  });
}
  1. Closure-local state. The queue and flag live inside initializeTabGroupListeners, not at module scope. That keeps them private to these listeners and invisible to the rest of the worker.
  2. Double-entry guard. Any call made while we're already draining is a no-op — the existing drain will pick up the newly pushed item. This is the line that makes the queue serial.
  3. One item per invocation. We handle a single group, not a while loop. Pairing this with step ⑤ gives each item its own setTimeout slot, which lets the service worker's event loop breathe between items.
  4. Error isolation. A failed group doesn't abort the queue. The catch logs and falls through to finally, which resets the flag and reschedules.
  5. Self-rescheduling. When more items are pending, a 1-second setTimeout queues the next drain. That gap gives Chrome time to settle, prevents quota bursts, and yields the worker to unrelated events.
  6. Fire-and-forget from the listener. Chrome's listener signature isn't async-friendly — we never await from inside it. Push to the queue, kick off the drain, return synchronously.

Dependency injection, lightly

The listener takes tabGroupManager as a parameter instead of importing a singleton. That keeps it testable (pass a fake) and makes the wiring visible in background.ts's composition root.

Try this
In the service worker's DevTools, paste: chrome.tabGroups.onCreated.addListener(g => console.log('GROUP', g)). Create a tab group in any window — watch the raw event fire. That's the signal our listener wraps.
Part 07 — Runtime

Calling Chrome APIs from TypeScript

The Chrome API surface is vast. TypeScript types make it tractable. Two calling styles coexist — always prefer Promises.

// Modern, preferred
const groups = await chrome.tabGroups.query({});

// Legacy — still valid, harder to compose
chrome.tabGroups.query({}, (groups) => { /* ... */ });

Walkthrough: TabGroupManager.getGroup

Manager methods wrap Chrome APIs with logging, retries, and fallbacks. getGroup is a small example that shows all three ideas at once.

// src/lib/tabGroupManager.ts
async getGroup(groupId: number): Promise<chrome.tabGroups.TabGroup | null> {  // ①
  try {
    const group = await new Promise<chrome.tabGroups.TabGroup | null>((resolve) => {
      chrome.tabGroups.get(groupId, (g) => resolve(g || null));               // ②
    });
    if (group) return group;                                                  // ③

    // Fallback: scan every window. Useful after window crash/restore.
    const windows = await chrome.windows.getAll();                            // ④
    for (const window of windows) {
      const groups = await chrome.tabGroups.query({ windowId: window.id });
      const match = groups.find(g => g.id === groupId);
      if (match) return match;
    }
    return null;                                                              // ⑤
  } catch (error) {
    this.logger.error('getGroup:failed', { groupId, error });
    return null;                                                              // ⑥
  }
}
  1. Nullable return type. Promise<TabGroup | null> — not Promise<TabGroup>. Chrome windows can close between an event firing and the handler reading the group, so null is a real outcome callers must handle at the type level.
  2. Callback-to-promise bridge. chrome.tabGroups.get here is used in its legacy callback form, wrapped into a promise so the rest of the method can await. The || null normalizes an undefined callback argument.
  3. Happy-path fast exit. 95% of calls find the group on the first try. We only descend into the fallback when the direct lookup misses.
  4. Fallback: brute-force scan. Why do this at all? Because Chrome has edge cases — window just restored, group just moved — where get(groupId) returns nothing even though the group exists in some window. Querying every window is O(windows) but windows.length is typically small.
  5. Genuine miss. If the scan also fails, the group is really gone. Return null; the caller decides whether that's an error or a no-op.
  6. Exceptions become null + a log line. A throw from chrome.* (rare, but possible) is indistinguishable to callers from "not found." One fewer branch at the call site, one more log entry to debug from.
Key idea
Callers of any Chrome API should assume the object may have disappeared by the time the query resolves. Types with | null force the caller to face it.
Part 08 — Runtime

Managers and composition

Past a few hundred lines of background logic, classes that bundle related state win over loose functions passing dictionaries. This project uses five managers, wired at startup.

// src/background.ts
async function initializeManagers() {
  storage = new StorageManager();
  await storage.initialize();

  bookmarkManager = new BookmarkManager(storage);
  syncEngine      = new SyncEngine(storage, bookmarkManager, null!);  // circular dep
  tabGroupManager = new TabGroupManager(syncEngine, storage);
  Object.assign(syncEngine, { tabGroupManager });                     // patched after

  snapshotManager = new SnapshotManager(storage, bookmarkManager);

  initializeTabGroupListeners(tabGroupManager, workerStartTime);
  initializeTabListeners(tabGroupManager);
  initializeBookmarkListeners(bookmarkManager);
}
ManagerResponsibility
StorageManagerPersist settings + history to chrome.storage.sync; hold runtime mappings in memory.
BookmarkManagerCreate/update bookmark folders that represent tab groups.
SyncEngineOrchestrator: given a group, make the bookmark folder match. Queues ops.
TabGroupManagerRead/mutate Chrome tab groups (e.g. move across windows).
SnapshotManagerCreate/restore point-in-time snapshots of a group.

Why classes?

State that belongs together — a sync queue, a map of move-guards, a hash of last-seen contents — is easier to pass around as a single object than as a bag of loose parameters. Constructors make dependencies explicit: a SyncEngine that needs storage and bookmarks takes both — nothing reaches into globals.

About that circular dep
SyncEngine needs TabGroupManager, which needs SyncEngine. We resolve it by constructing one with a null! placeholder, then patching in the real reference after both exist. It's not pretty — but it's honest. Circular deps happen when the domain has mutually recursive concerns, and the fix is usually "construct one, patch in the other," not "rearrange the domain."
Part 09 — Runtime

Popup ↔ background messaging

The popup and the service worker are separate processes. They talk via Chrome's message ports.

Walkthrough: popup-side initializeApp

When the toolbar icon is clicked, Chrome loads popup.html, which loads popup.js, which runs this. The React app does not mount until the service worker replies PONG.

// src/popup.tsx
async function initializeApp() {
  try {
    const port = chrome.runtime.connect({ name: 'popup' });    // ①

    await new Promise<void>((resolve, reject) => {
      const timeout = setTimeout(() => {                       // ②
        reject(new Error('Service worker connection timeout'));
      }, 5000);

      port.onMessage.addListener((message) => {
        clearTimeout(timeout);
        if (message.type === 'PONG')        resolve();          // ③
        else if (message.type === 'NOT_READY') reject(new Error(message.error));
        else                                reject(new Error('Unexpected response'));
      });

      port.onDisconnect.addListener(() => {                     // ④
        clearTimeout(timeout);
        if (chrome.runtime.lastError) reject(new Error('Failed to connect'));
      });

      port.postMessage({ type: 'PING' });                       // ⑤
    });

    const container = document.getElementById('root')!;
    createRoot(container).render(                               // ⑥
      <React.StrictMode>
        <ErrorBoundary><App /></ErrorBoundary>
      </React.StrictMode>
    );
  } catch (error) {
    document.body.innerHTML = `<div>Failed to Initialize…</div>`; // ⑦
  }
}
initializeApp();
  1. Open a named port. chrome.runtime.connect({ name: 'popup' }) fires chrome.runtime.onConnect in the service worker. The name is our convention so the worker can distinguish popup ports from any other context.
  2. Timeout as a reject path. A cold service worker may take a moment to wake. Five seconds is generous; anything longer and we'd rather show an error than spin.
  3. Two possible replies. PONG means managers are wired and it's safe to render. NOT_READY carries an error string the UI can display. Anything else means a protocol mismatch — treated as an error.
  4. Disconnect detection. If the service worker dies before responding, we never receive a message — onDisconnect fires instead. Without this handler, only the timeout would save us.
  5. Send ping last. Listeners must be attached before posting, otherwise a fast PONG could race past a not-yet-registered listener. Order matters.
  6. Render only on success. createRoot(...).render(...) mounts the React tree. Because we awaited the handshake, App is guaranteed to find a live service worker on its first message.
  7. Error fallback. On any rejection we replace document.body with a plain-HTML error card and a Retry button. No React involved — React itself might be why we failed.

Walkthrough: the App.tsx init effect

The popup side has a second handshake inside React. The PING done by initializeApp only proves the worker is alive; this useEffect is what builds the message-proxy managers the UI uses.

// src/components/App.tsx
export default function App() {
  const [isInitialized, setIsInitialized] = React.useState(false);  // ①
  const [error, setError] = React.useState<Error | null>(null);
  const [managers, setManagers] = React.useState<{ /*…*/ } | null>(null);

  React.useEffect(() => {                                           // ②
    const init = async () => {
      try {
        const port = chrome.runtime.connect({ name: 'popup' });     // ③
        await new Promise<void>((resolve, reject) => {
          const timeout = setTimeout(() => { cleanup(); reject(/*…*/); }, 5000);
          const messageHandler = (m: { type: string; error?: string }) => {
            if (m.type === 'PONG')      { cleanup(); resolve(undefined); }
            else if (m.type === 'NOT_READY') { cleanup(); reject(new Error(m.error)); }
          };
          const cleanup = () => {                                   // ④
            clearTimeout(timeout);
            port.onMessage.removeListener(messageHandler);
          };
          port.onMessage.addListener(messageHandler);
          port.postMessage({ type: 'PING' });
        });

        const storage: StorageManager = {                           // ⑤
          getSettings: async () => /* sendMessage('GET_SETTINGS') */,
          updateSettings: async (s) => /* sendMessage('UPDATE_SETTINGS', s) */,
          /* … */
        } as StorageManager;
        // bookmarkManager, syncEngine: same shape — each method = one sendMessage.

        setManagers({ storage, bookmarkManager, syncEngine, tabGroupManager });
        setIsInitialized(true);                                     // ⑥
      } catch (error) {
        setError(error as Error);                                   // ⑦
      }
    };
    void init();
  }, [logger]);                                                     // ⑧
  // … render branches on isInitialized / error / managers
}
  1. Three state atoms. isInitialized gates rendering, error triggers the error UI, managers is what child components consume. Splitting them makes each render branch trivial to check.
  2. useEffect for side effects after mount. React's contract: the function body is pure (builds the JSX), effects run after paint. Messaging Chrome is a side effect, so it goes here.
  3. Second PING — why? The popup.tsx handshake proved the worker was awake at that moment. Between then and now React has mounted App; the worker might have died and been restarted. This PING re-establishes the invariant for the lifetime of this component.
  4. Cleanup closure. Unlike popup.tsx, this handler removes its own listener when resolved. That matters because useEffect can re-run; leaking listeners onto the port would multiply handlers on every retry.
  5. Proxy managers. The popup doesn't instantiate real StorageManager classes — the real ones live in the service worker. Each method here is a thin chrome.runtime.sendMessage wrapper that matches the shape of the real manager. Components can't tell the difference.
  6. State flip = re-render. setManagers and setIsInitialized schedule a React render. Now the guard if (!isInitialized) return <Loading/> in the JSX becomes false and the real UI appears.
  7. Error branch. Any throw from the async init surfaces here as state, which a sibling branch of the JSX renders as an error card with a retry button (handleRetry, not shown).
  8. Dependency array. [logger] is effectively [] because logger is a singleton — the effect runs once per mount. A retryCount state is also added so handleRetry can force the effect to re-run.

Service worker side

// background.ts
chrome.runtime.onConnect.addListener((port) => {
  if (port.name !== 'popup') return;
  port.onMessage.addListener(async (msg) => {
    if (msg.type === 'PING') {
      const ready = await ensureInitialized();
      port.postMessage(ready ? { type: 'PONG' } : { type: 'NOT_READY' });
    }
  });
});

Two messaging APIs

  • chrome.runtime.connect → Port — long-lived, bidirectional, stays open while the popup is open. Used here for the handshake.
  • chrome.runtime.sendMessage + onMessage — fire-and-forget, one-shot. Used by every proxy manager method in App.tsx.
Debugging tip
DevTools' Network tab won't show messages — they're not HTTP. Use the service worker's DevTools console plus port.onMessage logs. The Logger structures every event so you can filter by operation.
Part 10 — Runtime

Persistence — storage.sync vs. bookmarks

Chrome gives extensions several places to store data. Picking wrong causes quota errors or lost state. This extension uses two storage surfaces by design.

chrome.storage.sync

Small, cross-device, synced via Google account. Good for settings. Strict limits: 100 KB total, 8 KB per item, 120 writes/min.

await chrome.storage.sync.set({
  'state:settings': this.persistedState.settings,
  'state:history':  this.persistedState.syncHistory.slice(-50),   // bounded!
});

.slice(-50) caps history to the last 50 entries — deliberately staying below quota. Unbounded arrays in storage.sync are a classic bug.

chrome.bookmarks — as a database

The product insight: bookmarks are already synced by Google, have no meaningful size limit, and are user-visible. So bookmark folders become the canonical storage for tab-group contents.

Bookmarks Bar └── Tab Group Bookmarks ← user-picked container ├── Work ← bookmark folder = tab group │ ├── github.com/... │ ├── linear.app/... │ └── mail.google.com/... ├── Research │ └── ... └── Tab Group Snapshots └── Work @ 2026-02-19 ← timestamped copies

Cross-device sync "just works" because it's the same sync layer Chrome uses for bookmarks. No account, no server, no API keys. That's the core product idea — falling out of picking the right storage medium.

SurfaceUse forAvoid for
chrome.storage.syncSmall settings, cross-device.Anything unbounded or >8 KB/item.
chrome.storage.localLarger local-only data (10 MB, or unlimited with permission).Things you need synced.
chrome.bookmarksUser-visible data, or large synced datasets.Secret/internal state.
IndexedDBStructured queries, large local data.Simple config.
Part 11 — Deep dives

The distributed sync model

This extension is a distributed system — multiple Chrome instances, each holding partial state, converging without a coordinating server. Understanding the model makes the trade-offs make sense.

The topology

┌─────────────┐ ┌──────────────────┐ ┌─────────────┐ │ Chrome on │ │ Google Bookmark │ │ Chrome on │ │ Laptop │◀─ sync ──▶│ Sync Service │◀── sync ──▶│ Phone │ │ (ext v1.2) │ │ (operated by G) │ │ (ext v1.2) │ └─────────────┘ └──────────────────┘ └─────────────┘ ▲ ▲ │ │ chrome.bookmarks.* chrome.bookmarks.*

Each Chrome talks to the Google bookmark sync service. Our extension talks only to chrome.bookmarks — Google handles replication, conflict resolution at the bookmark level, and offline queuing. We ride a distributed system rather than build one.

Two consistency gotchas

Bookmark IDs are local

The ID "42" on your laptop is a different bookmark on your phone. IDs are per-profile, assigned on arrival. Never persist an ID across devices. Identify bookmarks by stable content — folder title, or URL for leaf bookmarks. See src/lib/bookmarks/bookmarkQueries.ts's signature-based search.

Names-as-identifiers

Tab group titles are the stable identifier across devices. Two consequences:

  • Two groups with the same name share one bookmark folder (documented in docs/KNOWN_ISSUES.md).
  • Renaming a group could mean "delete the old folder, create a new one" — which would orphan the original and lose all bookmarks under it. Instead we rename the bookmark folder in place whenever the title changes. See Part 12, Algorithm 6.

Conflict resolution: last-write-wins

If your laptop and phone modify the same group at the same moment, one wins at the bookmark sync layer. We don't fight it — we accept eventual consistency. The product guarantee is "your tab groups are eventually durable," not "every edit is preserved."

Design thread to pull on
Compare this approach to what Workona, Toby, and Session Buddy do: they run their own backends, maintain accounts, and implement server-authoritative conflict resolution. We trade features (merge conflict UIs, access logs, analytics) for a simpler product boundary: your data never leaves your browser. Every architectural decision in this repo ladders up to that trade.
Part 12 — Deep dives

Special algorithms

Five algorithms earn their keep here. None are individually exotic — their value is in combining to handle the messy realities of Chrome's event model.

1. Walkthrough: processSyncQueue

src/lib/sync/syncEngine.ts serializes sync operations with a 4-second gap and a quota-aware backoff. This is the single loop every bookmark write passes through.

// src/lib/sync/syncEngine.ts
private readonly SYNC_DELAY = 4000;
private syncQueue: QueuedSync[] = [];
private processingQueue = false;
private readonly MAX_QUEUE_SIZE = 100;

private async processSyncQueue() {
  if (this.processingQueue || this.syncQueue.length === 0) return;      // ①
  this.processingQueue = true;
  try {
    while (this.syncQueue.length > 0) {                                 // ②
      const item = this.syncQueue.shift()!;

      try {
        await this.syncGroupToFolder(item.name);                        // ③
      } catch (error) {
        const isQuotaError = error instanceof Error &&
          error.message.includes('MAX_WRITE_OPERATIONS_PER_HOUR');      // ④

        if (isQuotaError && (!item.retryCount || item.retryCount < this.MAX_RETRIES)) {
          this.syncQueue.push({                                         // ⑤
            ...item,
            retryCount: (item.retryCount || 0) + 1,
            timestamp: Date.now() + 60000
          });
          await new Promise(r => setTimeout(r, 60000));                 // ⑥
        } else {
          this.logger.error('syncQueue:syncFailed', { /*…*/ });         // ⑦
        }
      }

      if (this.syncQueue.length > 0) {
        await new Promise(r => setTimeout(r, this.SYNC_DELAY));         // ⑧
      }
    }
  } finally {
    this.processingQueue = false;                                       // ⑨
  }
}
  1. Single-runner gate. Same idea as the listener queue in Part 06, but here we drain in a while loop rather than self-rescheduling. Either style works; this one has less setTimeout overhead.
  2. Drain loop. Keep pulling items until empty. New pushes from queueSync that arrive mid-drain are picked up on the next iteration — they notice processingQueue === true and skip starting their own loop.
  3. Per-item try. One bad group can't abort the whole queue. The try is inside the loop so we always continue.
  4. String-match the error. Chrome signals quota exhaustion via a message substring. It's fragile, but it's the surface Chrome gives us. A typed error class would be better — file a Chromium bug if you're motivated.
  5. Re-queue with backoff metadata. The item goes back to the tail with an incremented retryCount. After MAX_RETRIES, step ⑤'s branch fails and we fall through to ⑦ — dropping the item permanently rather than thrashing.
  6. Cool-off sleep. A full minute of nothing. The quota is per-hour, so a minute is enough to let a few slots free up without stalling the whole drain. Other queued items wait — by design.
  7. Terminal failure logs only. Non-quota errors (or exhausted retries) become log lines. The queue continues with the next item.
  8. The 4-second heartbeat. Between every item, a fixed gap. chrome.storage.sync and bookmarks both quota at 120 writes/min — 4 s/item is 15/min, comfortably under even if each sync fans out to several writes.
  9. Release the gate in finally. Even if an exception escapes the loop (shouldn't, but belt-and-suspenders), the next caller can start a fresh drain.

2. Move-guards

Moving a group across windows fires onRemoved on the source window and onCreated on the target. Without a guard we'd treat the "remove" as a delete and wipe the bookmark folder. Each move installs a short-lived guard keyed by group name:

interface MoveGuard { sourceGroupId?: number; targetGroupId: number; expiresAt: number; }
private moveGuards: Map<string, MoveGuard> = new Map();
// before move:        this.moveGuards.set(name, { targetGroupId, expiresAt: Date.now() + 3000 });
// in remove handler:  if (guardActive(name)) return;   // ignore stale event

3. Content signatures for bookmark matching

Because bookmark IDs vary across devices (see Part 11), we match bookmarks by a signature: a hash of the URL list. When sync resumes on a new device, we find the right folder by comparing signatures, not IDs.

4. Walkthrough: resolveGroupName

Fifty lines in src/lib/utils/groupNameResolver.ts. Tiny, but it teaches a property-test discovery and a design decision.

// src/lib/utils/groupNameResolver.ts
export function resolveGroupName(title: string | undefined): string | null {
  if (title === undefined || title === null || title === '') {  // ①
    logger.logDecision('Group skipped due to missing title', /*…*/);
    return null;
  }
  if (title.trim() === '') {                                     // ②
    logger.logDecision('Group ignored due to whitespace-only title', /*…*/);
    return null;
  }
  return title;                                                  // ③
}
  1. Truly-empty → null. Chrome creates tab groups before the user names them, so title is initially undefined. Returning null tells every caller "this group isn't ready to sync yet" — no folder created, no mapping stored.
  2. Whitespace-only → null. The property-test origin: fast-check kept generating titles like " " which, if treated as valid, created folders named " " — hostile to bookmark sync. Rather than sanitize, the product decision was to ignore these groups entirely. Documented in .kiro/specs/tab-group-sync/requirements.md (Requirement 15).
  3. Valid → pass through. No trim, no lowercase, no normalization. The title is the cross-device identifier (Part 11); mutating it would break name-based matching.

Callers use the null return as the signal to skip work. Compare tabGroupListeners.ts: if (name === null) return. Three lines of this function replace what would otherwise be scattered if (title && title.trim()) checks in a dozen places.

5. Bounded history retention

// Ring buffer + age pruning
const MAX_ENTRIES = 200;
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
history = history
  .filter(e => (now - e.timestamp) < MAX_AGE_MS)
  .slice(-MAX_ENTRIES);

Two independent cutoffs. Either condition can trigger eviction; whichever is tighter wins. This keeps storage.sync comfortably below quota without user configuration.

6. Title-change debounce with rename-in-place

Chrome fires chrome.tabGroups.onUpdated on every keystroke while the user types a group title. A naïve handler creates a bookmark folder for every intermediate string: type "splunk" and you get six folders — s, sp, spl, splu, splun, splunk. Two layers stacked make this correct for any typing pattern.

Layer 1 — listener-level debounce

// src/listeners/tabGroupListeners.ts
const UPDATE_DEBOUNCE_MS = 750;
const updateTimers = new Map<number, ReturnType<typeof setTimeout>>();
const latestUpdateByGroupId = new Map<number, chrome.tabGroups.TabGroup>();

chrome.tabGroups.onUpdated.addListener((group) => {
  latestUpdateByGroupId.set(group.id, group);           // ① always record newest

  const existing = updateTimers.get(group.id);
  if (existing) clearTimeout(existing);                 // ② reset on every event

  updateTimers.set(group.id, setTimeout(async () => {   // ③ fire only after silence
    updateTimers.delete(group.id);
    const latest = latestUpdateByGroupId.get(group.id);
    latestUpdateByGroupId.delete(group.id);
    if (!latest) return;
    await tabGroupManager.handleGroupUpdated(latest);   // ④ use settled value
  }, UPDATE_DEBOUNCE_MS));
});

chrome.tabGroups.onRemoved.addListener((group) => {
  const pending = updateTimers.get(group.id);           // ⑤ cancel on removal
  if (pending) { clearTimeout(pending); updateTimers.delete(group.id); }
  latestUpdateByGroupId.delete(group.id);
  // ... rest of removal logic
});
  1. Record, don't capture. If we captured group in the timer's closure, we'd sync the first keystroke's value ("s") instead of the last ("splunk"). Storing the latest separately and reading it at fire-time fixes this.
  2. Reset on every event. The debounce extends as long as events keep arriving — that's the whole point.
  3. Fire after silence. 750ms of no events means the user has likely stopped typing.
  4. Use the settled value. Whatever group happened to be in flight when the timer started is irrelevant.
  5. Cancel on removal. Otherwise the timer fires on a group that no longer exists, triggering a sync for a ghost.

Layer 2 — rename-in-place at the sync engine

Debounce alone is a liveness bet: if the user pauses to think for a second, 750ms fires too early. Without the second layer, pausing mid-typing still creates an intermediate folder. The fix: when a synced group's title changes, rename the existing bookmark folder instead of creating a new one.

// src/lib/sync/syncEngine.ts
async handleGroupRenamed(
  oldName: string, newName: string, group: chrome.tabGroups.TabGroup
): Promise<void> {
  const oldMapping = await this.storage.getMapping(oldName);

  if (oldMapping?.folderId) {
    await chrome.bookmarks.update(oldMapping.folderId, { title: newName });   // ①
    await this.storage.updateMapping(newName, {                               // ②
      name: newName,
      folderId: oldMapping.folderId,
      currentGroupId: group.id.toString(),
      color: group.color,
      syncEnabled: oldMapping.syncEnabled,
      status: oldMapping.status,
    });
    await this.storage.removeMapping(oldName);                                // ③
    if (oldMapping.syncEnabled) this.queueSync(newName);                      // ④
  } else {
    await this.handleGroupUpdated(group);                                     // ⑤
  }
}
  1. Rename the folder, don't recreate. chrome.bookmarks.update(id, {title}) keeps the same folder ID and all child bookmarks — only the label changes.
  2. Migrate the mapping under the new name. The runtime mapping is keyed by name (Part 11), so a rename requires re-keying. We reuse the original folderId, preserving the bookmark chain.
  3. Drop the old mapping entry to avoid two entries pointing at the same folder.
  4. Queue a content sync only if the group was enabled. Disabled groups stay put.
  5. Fallback for first-title assignment. If there's no mapping for oldName, this is a group that's just being named — hand it to the create path.
Why both layers
Debounce alone can fire mid-typing and orphan a folder (if the sync engine ran the old create-new path). Rename-in-place alone is correct for any pause length, but calls chrome.bookmarks.update on every keystroke — wasteful. Stacking both: debounce reduces churn, rename-in-place guarantees correctness. A user could pause for 10 minutes between characters and still end up with a single folder named the final title, bookmarks intact.
Pattern
None of these six is a big algorithm. They're all small rules applied at the right event boundary. That's the style: instead of one clever data structure, many tiny invariants at the places where events cross system boundaries. Together they make the extension reliable.
Part 13 — Deep dives

Testing: unit, property, E2E

Three layers, each catches different bugs. Running all three on every PR is cheap; skipping any of them is expensive.

Unit tests (Vitest)

Pure logic with a mocked chrome global. Fast (<1s each), run in Node.

// tests/unit/groupNameResolver.test.ts (sketch)
import { resolveGroupName } from '../../src/lib/utils/groupNameResolver';

test('returns "Unnamed Group" for undefined', () => {
  expect(resolveGroupName(undefined)).toBe('Unnamed Group');
});

test('returns null for whitespace-only', () => {
  expect(resolveGroupName('   ')).toBeNull();
  expect(resolveGroupName('\t\n')).toBeNull();
});

Property tests (fast-check)

Instead of a handful of examples, property tests generate 100+ random inputs and assert an invariant. They find edge cases you wouldn't think to write.

// tests/property/sync/property-01-idempotent-sync.test.ts (sketch)
import fc from 'fast-check';

test('syncing twice yields same bookmark folder as syncing once', async () => {
  await fc.assert(fc.asyncProperty(
    arbitraryGroup(),
    async (group) => {
      const engine = freshSyncEngine();
      await engine.syncGroup(group);
      const snapshot1 = await engine.listBookmarks(group.name);
      await engine.syncGroup(group);
      const snapshot2 = await engine.listBookmarks(group.name);
      expect(snapshot1).toEqual(snapshot2);   // idempotence
    },
  ));
});

When a property fails, fast-check shrinks the counterexample to the smallest input that still triggers the bug — hugely useful for diagnosis. The whitespace-only name case in Part 12 was discovered this way.

E2E tests (Playwright)

Real Chromium, extension loaded from dist/, tests drive the popup UI only — no internal API calls. This is the only layer that catches MV3-specific bugs like service worker restarts or port-disconnect races.

// tests/e2e/fixtures.ts (condensed)
const context = await chromium.launchPersistentContext(userDataDir, {
  headless: false,
  args: [`--disable-extensions-except=${EXT_PATH}`, `--load-extension=${EXT_PATH}`],
});
// tests/e2e/tab-group-sync.test.ts (sketch)
test('creating a tab group creates a bookmark folder', async ({ page, extensionId }) => {
  await setupExtensionViaUI(page, extensionId);
  await createAndSyncTabGroup(page, extensionId, { title: 'Work', urls: ['https://github.com'] });
  const folder = await waitForBookmarkFolder(page, 'Work');
  expect(folder).toBeTruthy();
});
LayerSpeedCatchesMisses
Unit<1 sPure-logic bugs, edge casesIntegration issues, Chrome API behavior
Property5–20 s per propertyInvariant violations across random inputsMulti-component flows
E2E20–60 s eachMV3 lifecycle, UI wiring, real Chrome APIs—
CI
See .github/workflows/ci.yml — unit + property run on every PR via npm test. E2E (slower) runs locally via npm run test:e2e. Releases are tag-driven (.github/workflows/release.yml) — a tag like v1.2.0 triggers build, zip, and GitHub Release with the extension attached.
Part 14 — Deep dives

Observability & debugging MV3

MV3's ephemeral worker makes stack traces lie. Good logging is the substitute. Every manager in this project funnels events through a structured Logger.

// src/lib/utils/logger.ts (shape)
class Logger {
  info(event: string, data?: Record<string, unknown>) { /* ... */ }
  warn(event: string, data?: Record<string, unknown>) { /* ... */ }
  error(event: string, data?: Record<string, unknown>) { /* ... */ }
}

// Usage
this.logger.info('tabGroup:visible:handled', { groupId: group.id, title: group.title });
this.logger.error('sync:periodic:failed', { error: err.message });

Event names follow subject:verb:outcome. Payloads are structured JSON. That makes log lines filterable (tabGroup:*, sync:*:failed) and — importantly for MV3 — correlatable across worker restarts via timestamps and operation IDs.

OperationTracker

For multi-step ops, a tracker spans the lifetime: begin → progress → complete/fail. The service worker might die mid-op — the log will show a begin with no matching terminal event. That asymmetry is the clue.

Three MV3 debugging realities

Stale closures after worker restart

A variable captured in a listener at module load time is fresh. But the listener you registered during a previous worker's init is still referenced in chrome.runtime.* — and when it fires in a reincarnated worker, the captured module-level variable is whatever the new worker initialized it to. Don't rely on listener-level closures for mutable state — hold state in manager instances that get rebuilt on init.

Forgotten ensureInitialized

If a new event type arrives and you skip the init guard, the handler may run with undefined managers. Symptom: silent no-op or Cannot read properties of undefined. Every top-level listener must call await ensureInitialized() first.

Alarm firing before init

Chrome can fire an alarm the instant a worker wakes — before your async init has finished. The ensureInitialized pattern (Part 5) makes this safe. But if you put init in a setTimeout(fn, 5000), the alarm will beat it. Don't.

DevTools entry points

  • Service worker: chrome://extensions → service worker link under your extension. Console + breakpoints.
  • Popup: right-click the extension icon → Inspect popup. React DevTools works here.
  • Background events: the Errors button on the extension card lists uncaught exceptions across worker generations.
  • Storage: in the popup's DevTools, Application tab → Storage → Extension.
Worth internalizing
In a long-running MV3 app, the single most valuable debugging skill is reading logs across worker lifetimes. Not reading stack traces from a single run.
Part 15 — Deep dives

Packaging and shipping

Going from working code to a Chrome Web Store listing. This is where the project-management part of extension development happens.

Tag-driven releases

# .github/workflows/release.yml
on:
  push:
    tags: ['v*.*.*']

jobs:
  release:
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run build
      - name: Verify tag matches manifest version
        run: |
          TAG="${GITHUB_REF_NAME#v}"
          MANIFEST=$(node -p "require('./manifest.json').version")
          [ "$TAG" = "$MANIFEST" ] || exit 1
      - run: cd dist && zip -r "../tab-group-sync-${GITHUB_REF_NAME}.zip" .
      - uses: softprops/action-gh-release@v2
        with: { files: tab-group-sync-*.zip, generate_release_notes: true }

The version-parity guard is the single most useful safeguard: tagging v1.3.0 when manifest.json still says 1.2.0 fails the build rather than shipping a confusing release.

Chrome Web Store checklist

  • Icon PNGs at 16, 48, 128 px (public/icons/)
  • Five screenshots at 1280×800 (see docs/store-assets/)
  • Promo tile at 440×280
  • Short description (≤132 chars) + full description
  • Public HTTPS privacy policy URL
  • Production zip of dist/ — pulled from the GitHub Release page
Final exercise
Pick a feature to add — say, a keyboard shortcut to save the current window as a group. Place each piece:
  1. Manifest: commands entry with a suggested keybinding.
  2. Listener: chrome.commands.onCommand in a new src/listeners/commandListeners.ts.
  3. Manager method: TabGroupManager.saveCurrentWindowAsGroup().
  4. Sync wiring: call syncEngine.syncGroup(newGroup) after creation.
  5. Spec: new folder under .kiro/specs/save-window-as-group/ with requirements, design, tasks.
  6. Tests: unit for manager method, E2E for the shortcut flow.
You now have the mental map to place every piece.
Where to go next
Read Chrome's Getting Started for topics this tutorial skipped: content scripts, host permissions, declarativeNetRequest, offscreen documents. Then skim .kiro/specs/ — it's the spec-driven record of how each feature was thought through before it was coded.
Part 16 — File URLs & Search

file:// URL sync — working around a Chromium limitation

Every Chromium browser silently drops file:// URLs from saved tab groups (bug #372978298). Bookmarks don't have this filter. This extension exploits that gap.

Walkthrough: the URL filter change

The entire capture path was blocked by a single filter. Here's the actual code before and after, from src/lib/bookmarks/bookmarkManager.ts inside syncGroupToFolder():

// src/lib/bookmarks/bookmarkManager.ts — syncGroupToFolder()

// BEFORE (line 369): only http and https passed through
.filter((tab): tab is { title: string; url: string } => {
  if (typeof tab.url !== 'string') return false;
  if (!tab.url.startsWith('http://') && !tab.url.startsWith('https://')) {  // ①
    this.logger.debug('sync:invalidTab', { url: tab.url, reason: 'non-http URL' });
    return false;
  }
  if (existingUrls.has(tab.url)) return false;                              // ②
  return true;
});

// AFTER: file:// URLs pass through, with canonicalization for dedup
.filter((tab): tab is { title: string; url: string } => {
  if (typeof tab.url !== 'string') return false;
  if (!isSyncableUrl(tab.url)) return false;                                // ③
  const canonical = isFileUrl(tab.url)
    ? canonicalize(tab.url, mappingConfig)                                  // ④
    : tab.url;
  if (existingUrls.has(canonical)) return false;                            // ⑤
  return true;
});
  1. The old gate. This line was why every file://, chrome://, edge://, and about: URL was silently dropped. It was a reasonable default — but it also blocked the one non-http scheme users actually need.
  2. Old dedup. Compared raw URLs. This worked because all URLs were http/https — the same URL string on every machine.
  3. The new gate. isSyncableUrl() returns true for http://, https://, and file://. Browser-internal URLs (chrome://, edge://, about:) are still dropped.
  4. Canonicalize before dedup. A file:// URL like /home/bar/Dropbox/ch1.html (Linux) gets rewritten to /Users/foo/Dropbox/ch1.html (canonical form) before the duplicate check. This prevents the flip-flop problem (Part 17).
  5. New dedup. Compares the canonicalized URL against existing bookmarks (which are also canonicalized). Two machines syncing the same file produce the same canonical URL — no duplicates.

The restore path — already worked

The restore handler in src/background.ts (line ~600) already opened any bookmark URL without filtering. The only addition: apply localize() to rewrite canonical paths to local paths before opening, and fall back to opener.html if chrome.tabs.create() fails.

// src/background.ts — RESTORE_GROUP_FROM_BOOKMARKS handler
const mappingConfig = await storage.getPathMappingConfig();
for (const url of urls) {
  const resolvedUrl = isFileUrl(url) ? localize(url, mappingConfig) : url;  // ①
  try {
    const tab = await chrome.tabs.create({ url: resolvedUrl, active: false });
    createdTabs.push(tab);
  } catch (error) {
    if (isFileUrl(resolvedUrl)) {                                            // ②
      const openerUrl = chrome.runtime.getURL('opener.html')
        + '?target=' + encodeURIComponent(resolvedUrl)
        + '&original=' + encodeURIComponent(url);
      const tab = await chrome.tabs.create({ url: openerUrl, active: false });
      createdTabs.push(tab);
    } else {
      throw error;
    }
  }
}
  1. Localize. Canonical bookmark URL file:///Users/foo/Dropbox/ch1.html → local file:///home/bar/Dropbox/ch1.html. Non-file URLs pass through unchanged.
  2. Fallback. If chrome.tabs.create throws (typically "Allow access to file URLs" not enabled), open the opener page instead. The opener shows the path, setup instructions, and a manual "Try opening" button.

The full capture flow

Tab URL arrives at syncGroupToFolder() │ isSyncableUrl()? │ ┌────┼────────────────┐ │ │ │ YES │ NO → dropped (chrome://, edge://, about://) │ │ │ isFileUrl()? │ │ ┌────┼──────┐ │ │ YES NO → use URL as-is │ canonicalize() → rewrite local prefix to canonical │ ▼ dedup check against existing bookmarks (also canonicalized) │ ▼ chrome.bookmarks.create({ url: canonicalUrl })
Why bookmarks work but saved groups don't
Chrome's "Saved Tab Group" feature uses a separate sync type that explicitly filters file:// at save time. Bookmarks are older, simpler infrastructure — they store any URL string. Our extension rides the bookmark path, bypassing the saved-group filter entirely.
Spec
Full requirements, design, and task tracking: .kiro/specs/local-file-url-sync/
Part 17 — File URLs & Search

Cross-machine path mapping — the flip-flop fix

File paths differ across machines: /Users/foo/Dropbox on Mac, /home/foo/Dropbox on Linux, C:\Users\foo\Dropbox on Windows. Without canonicalization, each machine writes its own path variant to bookmarks — creating duplicates that compound on every sync cycle.

Bidirectional mapping

Each machine has a config: a canonical prefix (what goes in bookmarks) and a local prefix (what opens on this machine). Mapping works in both directions.

Concrete example: two machines, one Dropbox folder

Machine "mac" config: Machine "linux" config: canonical: /Users/foo/Dropbox canonical: /Users/foo/Dropbox local: /Users/foo/Dropbox local: /home/bar/Dropbox (same — this IS the canonical machine) (different — needs rewriting) Mac captures a tab: Linux captures the same file: tab URL: file:///Users/foo/ tab URL: file:///home/bar/ Dropbox/ch1.html Dropbox/ch1.html canonicalize: no-op (already canonical) canonicalize: → file:///Users/foo/ bookmark: file:///Users/foo/ Dropbox/ch1.html Dropbox/ch1.html bookmark: file:///Users/foo/ Dropbox/ch1.html ↓ Both write the SAME bookmark URL ↓ ↓ No duplicates — dedup works ↓ Linux restores from bookmark: Mac restores from bookmark: bookmark: file:///Users/foo/ bookmark: file:///Users/foo/ Dropbox/ch1.html Dropbox/ch1.html localize: → file:///home/bar/ localize: no-op Dropbox/ch1.html opens: file:///Users/foo/ opens: file:///home/bar/ Dropbox/ch1.html Dropbox/ch1.html

Walkthrough: canonicalize() and localize()

From src/lib/utils/pathMapper.ts — pure functions, no state, no Chrome APIs:

// src/lib/utils/pathMapper.ts

export function canonicalize(fileUrl: string, config: PathMappingConfig): string {
  if (!isFileUrl(fileUrl) || config.rules.length === 0) return fileUrl;    // ①

  const path = extractPath(fileUrl);                                       // ②
  const rule = findMatchingRule(path, config.rules, r => r.localPrefix);   // ③
  if (!rule) return fileUrl;                                               // ④

  const newPath = rewritePath(path, rule.localPrefix, rule.canonicalPrefix);
  const afterPath = fileUrl.slice(FILE_PROTOCOL.length + path.length);     // ⑤
  return FILE_PROTOCOL + newPath + afterPath;
}

export function localize(fileUrl: string, config: PathMappingConfig): string {
  if (!isFileUrl(fileUrl) || config.rules.length === 0) return fileUrl;

  const path = extractPath(fileUrl);
  const rule = findMatchingRule(path, config.rules, r => r.canonicalPrefix); // ⑥
  if (!rule) return fileUrl;

  const newPath = rewritePath(path, rule.canonicalPrefix, rule.localPrefix);
  const afterPath = fileUrl.slice(FILE_PROTOCOL.length + path.length);
  return FILE_PROTOCOL + newPath + afterPath;
}
  1. Early return for non-file URLs. http:// and https:// URLs pass through untouched. No mapping config = no rewriting — graceful degradation for unconfigured machines.
  2. Extract the path. file:///home/bar/Dropbox/ch1.html#section → /home/bar/Dropbox/ch1.html. Strips the protocol and any fragment/query.
  3. Find matching rule. canonicalize matches against localPrefix — "does this path start with my local prefix?" Uses longest-prefix match when multiple rules apply.
  4. No match = no rewrite. If the path doesn't match any rule, return as-is. This handles file:// URLs outside mapped directories.
  5. Preserve fragments. #section and ?query are re-appended after rewriting the path.
  6. The mirror. localize is structurally identical to canonicalize — it just matches against canonicalPrefix instead of localPrefix, and rewrites in the opposite direction.

Property tests prove correctness

Four properties verified by fast-check across hundreds of random URL/config combinations:

// tests/property/bookmarks/property-file-url-mapping.test.ts

// 1. Round-trip: localize undoes canonicalize
localize(canonicalize(url, config), config) === url

// 2. Idempotency: canonicalizing twice = canonicalizing once
canonicalize(canonicalize(url, config), config) === canonicalize(url, config)

// 3. http passthrough: non-file URLs are never modified
canonicalize('https://example.com', anyConfig) === 'https://example.com'

// 4. No-mapping passthrough: empty config = no rewriting
canonicalize(anyFileUrl, { machineId: '', rules: [] }) === anyFileUrl

Storage design

Path mappings live under their own chrome.storage.sync key (state:pathMappings), separate from state:settings. This prevents the background's saveState() from overwriting mapping config — a bug that took three rounds to fix (the popup writes directly to sync storage, bypassing the background's in-memory state).

chrome.storage.sync: state:pathMappings → { ← separate key, never clobbered machines: { "mac": { rules: [{ canonical: "/Users/foo/Dropbox", local: "/Users/foo/Dropbox" }] }, "linux": { rules: [{ canonical: "/Users/foo/Dropbox", local: "/home/bar/Dropbox" }] } } } chrome.storage.local: machineId → "linux" ← per-machine, not synced
Lesson learned
Never nest transient config inside a storage object that another subsystem reads and writes. The background's saveState() does a full overwrite of state:settings — any field added by the popup gets silently wiped. Separate keys are the fix.
Part 18 — File URLs & Search

Fuzzy search and restore actions

A search bar at the top of the popup that merges bookmarks and open tabs into one fuzzy-searchable index, showing the sync relationship between each URL.

Walkthrough: building the search index

From src/components/SearchBar.tsx — the useEffect that runs on focus:

// src/components/SearchBar.tsx — index building (simplified)

React.useEffect(() => {
  if (!focused) return;                                                     // ①

  (async () => {
    const [tabs, tabGroups] = await Promise.all([                           // ②
      chrome.tabs.query({}),
      chrome.tabGroups.query({})
    ]);

    const items: SearchResult[] = [];
    const seen = new Set<string>();

    // Phase 1: index all grouped tabs
    for (const tab of tabs) {
      if (tab.groupId === -1 || !groupMap[tab.groupId]) continue;           // ③
      seen.add(tab.url);
      items.push({ url: tab.url, title: tab.title, groupName: ...,
                   inTab: true, inBookmark: false,
                   isFile: tab.url.startsWith('file://') });
    }

    // Phase 2: index bookmarks, merge with tabs by URL
    for (const group of groups) {
      const bms = await chrome.bookmarks.getChildren(group.id);
      for (const bm of bms) {
        if (seen.has(bm.url)) {
          items.find(i => i.url === bm.url)!.inBookmark = true;            // ④
          continue;
        }
        items.push({ ..., inTab: false, inBookmark: true });                // ⑤
      }
    }

    setHaystack(items.map(i => `${i.title} ${i.url} ${i.groupName}`));     // ⑥
  })();
}, [focused]);
  1. Load on focus only. The index isn't built on mount — it waits until the user clicks the search bar. Avoids unnecessary Chrome API calls when search isn't used.
  2. Parallel load. Tabs and tab groups are independent queries — Promise.all saves a round trip.
  3. Grouped tabs only. Ungrouped tabs aren't part of this extension's model. groupId === -1 means "not in any group."
  4. Merge by URL. If a bookmark URL matches an open tab, that item gets inBookmark = true — making it "synced" (both open AND backed up).
  5. Bookmark-only items. URLs in bookmarks but not currently open get inTab: false — shown as "saved" in results.
  6. The haystack. uFuzzy searches concatenated strings: "React Hooks Guide https://react.dev/hooks Web Development". Searching "react hooks" or "web dev react" both match.

Sync status chips

ChipMeaningClick action
synced (green)Open in tab + backed up in bookmarksSwitch to that tab
tabOpen but not yet backed upSwitch to that tab
savedBacked up but not currently openOpen as new tab
file (blue)A file:// URLOpen with path mapping applied

Why uFuzzy over Fuse.js

uFuzzy is ~3KB (vs Fuse.js at ~25KB), faster, and better at URL patterns with its intraMode: 1 setting (allows gaps within words). The search pipeline is three calls:

const idxs = uf.filter(haystack, query);     // fast pre-filter
const info = uf.info(idxs, haystack, query); // match quality scoring
const order = uf.sort(info, haystack, query); // rank by relevance

The restore dropdown

Each group has a cloud-download icon that opens a menu with four restore modes:

  • Restore all tabs — opens all bookmarks, skips already-open URLs
  • Add missing tabs — same as restore all (only missing)
  • Add file:// tabs only — opens local file bookmarks with path mapping
  • Replace group (destructive) — closes existing tabs, restores fresh

All restore logic runs in the popup context, not the service worker. This avoids MV3 service worker lifecycle issues where the SW dies before async restore operations complete. The popup has access to the same chrome.bookmarks and chrome.tabs APIs and stays alive as long as it's open.

Architecture decision
The original implementation sent messages to the background service worker for restore operations. The service worker kept dying mid-execution — a known MV3 limitation for long-running async work triggered by one-shot messages. Moving the logic to the popup fixed it completely, at the cost of duplicating some Chrome API calls between contexts. Pragmatism over purity.
Try this
Open the extension popup, type a keyword in the search bar. Watch results appear grouped by tab group, with sync status chips. Click a "saved" result — it opens a new tab. Click a "tab" result — it switches to the existing tab. Try a file:// result with path mapping configured.