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.
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.
manifest.json— the one file Chrome reads first. Notebackground.service_worker,action.default_popup, and thepermissionslist. (Part 01)src/popup.html— the tiny shell loaded by the toolbar icon. It mounts one<div id="root">and loadspopup.js.src/popup.tsx— readinitializeApp(). PING/PONG handshake before the React app is allowed to mount. (Part 09 walkthrough)src/components/App.tsx— read only the firstuseEffect(lines ~72–228). It's the popup-side of the handshake plus the message-proxy managers. (Part 09 walkthrough)src/background.ts— the service worker entry. Read the first 75 lines: managers declared,ensureInitialized,initializeManagers. (Part 05, 08 walkthroughs)src/background.ts(continued) — scroll tochrome.alarms.onAlarmandchrome.runtime.onConnect. Those are the two entry points that woke you up. (Part 05, 09)src/listeners/tabGroupListeners.ts— readprocessGroupQueue. This is the event-queue pattern used everywhere. (Part 06 walkthrough)src/lib/tabGroupManager.ts— readgetGroup()(top of file). Fallback plus nullable return. (Part 07 walkthrough)src/lib/sync/syncEngine.ts— readprocessSyncQueue(around line 44). The drain loop with quota-aware backoff. (Part 12 walkthrough)src/lib/utils/groupNameResolver.ts— 50 lines; three input cases, one of which returnsnull. Property-test origin story. (Part 12)src/lib/bookmarks/bookmarkManager.ts— glance at the public method surface. This is what theSyncEnginecalls into.src/lib/storage/storageManager.ts— see how a manager holds in-memory state and persists bounded slices tochrome.storage.sync. (Part 10)src/lib/utils/logger.ts— thesubject:verb:outcomeevent naming you'll see everywhere. (Part 14)tests/unit/groupNameResolver.test.ts— a 20-line Vitest file. See howchrome.*mocking is avoided entirely for pure utils. (Part 13)tests/property/sync/andtests/e2e/— skim one of each. Three test layers, three different truths. (Part 13)src/lib/utils/pathMapper.ts— pure functions for bidirectional URL prefix rewriting. No state, no Chrome APIs. (Part 16, 17)src/components/SearchBar.tsx— fuzzy search across tabs + bookmarks with sync-status chips. (Part 18)
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_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,alarmsmap 1:1 tochrome.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
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.
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.
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
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',
},
});
emptyOutDir. Pass 1 sets this totrue— it wipesdist/clean before writing popup files. Pass 2 sets it tofalse— it addsbackground.jsalongside the popup output without deleting it.- Pass 2 entry.
src/background.tsimports everything it needs: StorageManager, BookmarkManager, SyncEngine, listeners, PathMapper. Vite follows every import and inlines them all into one file. - Pass 1 entry.
src/popup.tsxis the React app. Vite builds it normally with code-splitting — React, MUI, and app code may end up in separate chunks underdist/assets/. inlineDynamicImports. This is the critical flag. Without it, Vite would producebackground.jsplus separate chunk files — and Chrome would reject the service worker because it can't resolveimport './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.
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.
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).
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
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.
chrome.* events only? → listeners. Pure business logic? → lib. If
three pieces live in different places, the bridge between them usually belongs in a manager.
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>
| React | Svelte | |
|---|---|---|
| Reactivity | Runtime diff via virtual DOM | Compile-time, mutates DOM directly |
| State | useState hook returns pair | Top-level let |
| Effects | useEffect(fn, deps) | $: label & onMount |
| Bundle size | Larger (React runtime + MUI) | Smaller (compiled away) |
| Ecosystem | Enormous component libs | Smaller, 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.
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;
}
- Hot path. Once
initializeManagers()has setisReady = true, every subsequent call is a single boolean read. Zero cost, no microtask. This matters because every message handler starts withawait ensureInitialized(). - Concurrency guard. If two events fire on a fresh worker at the same moment, the second caller sees
initPromisealready exists and awaits it. Without this line you'd double-init managers and double-register listeners. - 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. - 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. - Clear on settle. Whether init succeeded or failed,
initPromiseis reset tonull. If it failed, the next event will retry from scratch. If it succeeded,isReadyis nowtrueso 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();
});
ensureInitialized() is called at the top of each handler.
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
});
}
- 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. - 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.
- One item per invocation. We handle a single group, not a
whileloop. Pairing this with step ⑤ gives each item its ownsetTimeoutslot, which lets the service worker's event loop breathe between items. - Error isolation. A failed group doesn't abort the queue. The
catchlogs and falls through tofinally, which resets the flag and reschedules. - Self-rescheduling. When more items are pending, a 1-second
setTimeoutqueues the next drain. That gap gives Chrome time to settle, prevents quota bursts, and yields the worker to unrelated events. - Fire-and-forget from the listener. Chrome's listener signature isn't
async-friendly — we neverawaitfrom 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.
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.
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; // ⑥
}
}
- Nullable return type.
Promise<TabGroup | null>— notPromise<TabGroup>. Chrome windows can close between an event firing and the handler reading the group, sonullis a real outcome callers must handle at the type level. - Callback-to-promise bridge.
chrome.tabGroups.gethere is used in its legacy callback form, wrapped into a promise so the rest of the method canawait. The|| nullnormalizes anundefinedcallback argument. - 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.
- 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) butwindows.lengthis typically small. - 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. - Exceptions become
null+ a log line. A throw fromchrome.*(rare, but possible) is indistinguishable to callers from "not found." One fewer branch at the call site, one more log entry to debug from.
| null force the caller to face it.
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);
}
| Manager | Responsibility |
|---|---|
| StorageManager | Persist settings + history to chrome.storage.sync; hold runtime mappings in memory. |
| BookmarkManager | Create/update bookmark folders that represent tab groups. |
| SyncEngine | Orchestrator: given a group, make the bookmark folder match. Queues ops. |
| TabGroupManager | Read/mutate Chrome tab groups (e.g. move across windows). |
| SnapshotManager | Create/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.
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."
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();
- Open a named port.
chrome.runtime.connect({ name: 'popup' })fireschrome.runtime.onConnectin the service worker. Thenameis our convention so the worker can distinguish popup ports from any other context. - 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.
- Two possible replies.
PONGmeans managers are wired and it's safe to render.NOT_READYcarries an error string the UI can display. Anything else means a protocol mismatch — treated as an error. - Disconnect detection. If the service worker dies before responding, we never receive a message —
onDisconnectfires instead. Without this handler, only the timeout would save us. - Send ping last. Listeners must be attached before posting, otherwise a fast
PONGcould race past a not-yet-registered listener. Order matters. - Render only on success.
createRoot(...).render(...)mounts the React tree. Because we awaited the handshake,Appis guaranteed to find a live service worker on its first message. - Error fallback. On any rejection we replace
document.bodywith 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
}
- Three state atoms.
isInitializedgates rendering,errortriggers the error UI,managersis what child components consume. Splitting them makes each render branch trivial to check. useEffectfor 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.- 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. - Cleanup closure. Unlike
popup.tsx, this handler removes its own listener when resolved. That matters becauseuseEffectcan re-run; leaking listeners onto the port would multiply handlers on every retry. - Proxy managers. The popup doesn't instantiate real
StorageManagerclasses — the real ones live in the service worker. Each method here is a thinchrome.runtime.sendMessagewrapper that matches the shape of the real manager. Components can't tell the difference. - State flip = re-render.
setManagersandsetIsInitializedschedule a React render. Now the guardif (!isInitialized) return <Loading/>in the JSX becomes false and the real UI appears. - 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). - Dependency array.
[logger]is effectively[]becauseloggeris a singleton — the effect runs once per mount. AretryCountstate is also added sohandleRetrycan 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.
port.onMessage logs. The Logger structures every event so you can filter by operation.
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.
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.
| Surface | Use for | Avoid for |
|---|---|---|
chrome.storage.sync | Small settings, cross-device. | Anything unbounded or >8 KB/item. |
chrome.storage.local | Larger local-only data (10 MB, or unlimited with permission). | Things you need synced. |
chrome.bookmarks | User-visible data, or large synced datasets. | Secret/internal state. |
| IndexedDB | Structured queries, large local data. | Simple config. |
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
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."
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; // ⑨
}
}
- Single-runner gate. Same idea as the listener queue in Part 06, but here we drain in a
whileloop rather than self-rescheduling. Either style works; this one has lesssetTimeoutoverhead. - Drain loop. Keep pulling items until empty. New pushes from
queueSyncthat arrive mid-drain are picked up on the next iteration — they noticeprocessingQueue === trueand skip starting their own loop. - Per-item try. One bad group can't abort the whole queue. The
tryis inside the loop so we always continue. - 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.
- Re-queue with backoff metadata. The item goes back to the tail with an incremented
retryCount. AfterMAX_RETRIES, step ⑤'s branch fails and we fall through to ⑦ — dropping the item permanently rather than thrashing. - 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.
- Terminal failure logs only. Non-quota errors (or exhausted retries) become log lines. The queue continues with the next item.
- The 4-second heartbeat. Between every item, a fixed gap.
chrome.storage.syncand bookmarks both quota at 120 writes/min — 4 s/item is 15/min, comfortably under even if each sync fans out to several writes. - 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; // ③
}
- Truly-empty →
null. Chrome creates tab groups before the user names them, sotitleis initially undefined. Returningnulltells every caller "this group isn't ready to sync yet" — no folder created, no mapping stored. - Whitespace-only →
null. The property-test origin:fast-checkkept 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). - 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
});
- Record, don't capture. If we captured
groupin 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. - Reset on every event. The debounce extends as long as events keep arriving — that's the whole point.
- Fire after silence. 750ms of no events means the user has likely stopped typing.
- Use the settled value. Whatever
grouphappened to be in flight when the timer started is irrelevant. - 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); // ⑤
}
}
- Rename the folder, don't recreate.
chrome.bookmarks.update(id, {title})keeps the same folder ID and all child bookmarks — only the label changes. - 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. - Drop the old mapping entry to avoid two entries pointing at the same folder.
- Queue a content sync only if the group was enabled. Disabled groups stay put.
- 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.
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.
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();
});
| Layer | Speed | Catches | Misses |
|---|---|---|---|
| Unit | <1 s | Pure-logic bugs, edge cases | Integration issues, Chrome API behavior |
| Property | 5–20 s per property | Invariant violations across random inputs | Multi-component flows |
| E2E | 20–60 s each | MV3 lifecycle, UI wiring, real Chrome APIs | — |
.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.
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.
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
- Manifest:
commandsentry with a suggested keybinding. - Listener:
chrome.commands.onCommandin a newsrc/listeners/commandListeners.ts. - Manager method:
TabGroupManager.saveCurrentWindowAsGroup(). - Sync wiring: call
syncEngine.syncGroup(newGroup)after creation. - Spec: new folder under
.kiro/specs/save-window-as-group/with requirements, design, tasks. - Tests: unit for manager method, E2E for the shortcut flow.
declarativeNetRequest, offscreen documents. Then skim
.kiro/specs/
— it's the spec-driven record of how each feature was thought through before it was coded.
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;
});
- The old gate. This line was why every
file://,chrome://,edge://, andabout:URL was silently dropped. It was a reasonable default — but it also blocked the one non-http scheme users actually need. - Old dedup. Compared raw URLs. This worked because all URLs were http/https — the same URL string on every machine.
- The new gate.
isSyncableUrl()returnstrueforhttp://,https://, andfile://. Browser-internal URLs (chrome://,edge://,about:) are still dropped. - 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). - 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;
}
}
}
- Localize. Canonical bookmark URL
file:///Users/foo/Dropbox/ch1.html→ localfile:///home/bar/Dropbox/ch1.html. Non-file URLs pass through unchanged. - Fallback. If
chrome.tabs.createthrows (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
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.
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
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;
}
- Early return for non-file URLs.
http://andhttps://URLs pass through untouched. No mapping config = no rewriting — graceful degradation for unconfigured machines. - Extract the path.
file:///home/bar/Dropbox/ch1.html#section→/home/bar/Dropbox/ch1.html. Strips the protocol and any fragment/query. - Find matching rule.
canonicalizematches againstlocalPrefix— "does this path start with my local prefix?" Uses longest-prefix match when multiple rules apply. - No match = no rewrite. If the path doesn't match any rule, return as-is. This handles file:// URLs outside mapped directories.
- Preserve fragments.
#sectionand?queryare re-appended after rewriting the path. - The mirror.
localizeis structurally identical tocanonicalize— it just matches againstcanonicalPrefixinstead oflocalPrefix, 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).
saveState() does a full overwrite of
state:settings — any field added by the popup gets silently wiped. Separate
keys are the fix.
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]);
- 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.
- Parallel load. Tabs and tab groups are independent queries —
Promise.allsaves a round trip. - Grouped tabs only. Ungrouped tabs aren't part of this extension's model.
groupId === -1means "not in any group." - Merge by URL. If a bookmark URL matches an open tab, that item gets
inBookmark = true— making it "synced" (both open AND backed up). - Bookmark-only items. URLs in bookmarks but not currently open get
inTab: false— shown as "saved" in results. - 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
| Chip | Meaning | Click action |
|---|---|---|
synced (green) | Open in tab + backed up in bookmarks | Switch to that tab |
tab | Open but not yet backed up | Switch to that tab |
saved | Backed up but not currently open | Open as new tab |
file (blue) | A file:// URL | Open 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.