DEV Community

SEN LLC
SEN LLC

Posted on

A Per-URL TODO List Chrome Extension in 150 Lines — Single-Key chrome.storage Design, URL Normalization, and Three Sync Paths for the Toolbar Badge

A flat TODO app forces you to paste in URLs every time you want to remember "three things to come back to in this PR" or "where I left off in this article." A Chrome extension that pins the list to whichever page you're on is the obvious shape — but the design choices around URL identity, storage layout, and badge sync are interesting enough to be worth writing up.

150 lines + 21 tests + zero host_permissions.

page-todo hosted playground showing three TODOs scoped to https://example.com/post/42 (one done, two open) and the raw chrome.storage.local JSON shape unfolded in a details panel below. Dark theme.

🧩 Demo: https://sen.ltd/portfolio/page-todo/
📦 GitHub: https://github.com/sen-ltd/page-todo

Storage shape: pick one key

Three plausible layouts for chrome.storage.local:

Option Shape Pros Cons
A { "https://...": [...todos] } at the top level Flat, write-per-URL Top level pollution; storage.get(null) mixes data with anything else
B { todos: { url: [...] } } under a single top key One read covers everything; onChanged listener watches one key Whole-store rewrites on every edit
C One key per URL (url:https://...) Partial writes Listener glue is messy; no clean "list all"

B wins for this size of data. A typical user holds a few KB to tens of KB. The "rewrite the whole thing every edit" cost is a few milliseconds; the simplicity of chrome.storage.onChanged triggering on a single named key is worth it.

const KEY = "todos";

// Storage shape
{
  todos: {
    "https://example.com/post/42": [
      { id, text, done, created },
      ...
    ],
    "https://github.com/sen-ltd/page-todo/pulls": [...]
  }
}
Enter fullscreen mode Exit fullscreen mode

URL normalisation — eat the ?utm_source and #section

What counts as "the same page"? If you store the raw URL as the key, these all become different pages:

  • https://example.com/post/42
  • https://example.com/post/42?utm_source=newsletter
  • https://example.com/post/42#comments

That's clearly wrong UX. Tracking parameters land you on the same article from a Twitter click; in-page anchors take you to a different section but the same article. The list should be one list.

The normalisation: origin + pathname, drop query and hash:

function urlKey(rawUrl) {
  if (!rawUrl) return null;
  try {
    const u = new URL(rawUrl);
    if (u.protocol === "chrome:" || u.protocol === "about:" || u.protocol === "file:") {
      return rawUrl;  // internal-page schemes pass through verbatim
    }
    let path = u.pathname;
    if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1);
    return u.origin + path;
  } catch {
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

Trailing slash is also normalised (so /post/ and /post share a list), but the root path / keeps its slash so the key is non-empty.

chrome://, about:, and file:// URLs aren't worth disassembling and are rare enough that "verbatim" is fine.

When query is the page

For older ?id=42-driven web apps, dropping the query loses the page identity. v1 doesn't try to be clever about this — supporting it would mean a per-host allow-list or a manual "save as separate page" flag, both of which are complexity buying very little. SPA-only sites are a strong majority now; the few pre-SPA cases that suffer can paste raw URLs into a flat note app instead.

Toolbar badge — three sync paths into one helper

The badge shows the open-TODO count for the active tab's URL. That value can change for three reasons:

  1. The user switches tabschrome.tabs.onActivated
  2. A tab's URL changes (link click, SPA navigation) → chrome.tabs.onUpdated
  3. The storage changes (popup adds/removes/toggles a TODO) → chrome.storage.onChanged

All three call the same helper:

async function refreshBadgeForTab(tabId, url) {
  const count = await openCountFor(storage, url);
  const text = count > 0 ? String(count) : "";
  await chrome.action.setBadgeText({ tabId, text });
  if (count > 0) {
    await chrome.action.setBadgeBackgroundColor({ tabId, color: "#58a6ff" });
  }
}

chrome.tabs.onActivated.addListener(async ({ tabId }) => {
  const tab = await chrome.tabs.get(tabId);
  if (tab?.url) await refreshBadgeForTab(tabId, tab.url);
});

chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
  if (changeInfo.url || changeInfo.status === "complete") {
    if (tab?.url) await refreshBadgeForTab(tabId, tab.url);
  }
});

chrome.storage.onChanged.addListener(async (changes, area) => {
  if (area !== "local" || !changes.todos) return;
  const tabs = await chrome.tabs.query({ active: true });
  for (const t of tabs) {
    if (t.url && t.id !== undefined) await refreshBadgeForTab(t.id, t.url);
  }
});
Enter fullscreen mode Exit fullscreen mode

The onUpdated filter — changeInfo.url || changeInfo.status === "complete" — is the small thing that makes this feel right:

  • SPA in-place navigation fires only changeInfo.url.
  • Full navigations end with changeInfo.status === "complete".

Listening to either alone leaves the other case stale.

The changes.todos check on onChanged keeps unrelated keys from triggering recompute, in case you ever add other state.

Pruning empty URL keys keeps the store tight

Removing a TODO leaves an empty array. Letting those accumulate makes the store bloat with URLs the user effectively cleared:

async function removeTodo(storage, rawUrl, id) {
  const all = await loadAll(storage);
  const list = all[key];
  list.splice(idx, 1);
  if (list.length === 0) delete all[key];   // prune
  else all[key] = list;
  await saveAll(storage, all);
}
Enter fullscreen mode Exit fullscreen mode

clearDone does the same. After a year of normal use, the URL key set stays bounded by currently relevant pages, not every page ever visited.

ID generation — crypto.getRandomValues over Date.now()

Two adds in the same millisecond — easy to trigger from the popup if the user spams Enter — would collide on a Date.now()-based ID. Use the WebCrypto random instead:

function makeId() {
  const arr = new Uint8Array(5);
  crypto.getRandomValues(arr);
  return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join("").slice(0, 9);
}
Enter fullscreen mode Exit fullscreen mode

A test (one of 21) explicitly proves "100 rapid adds in the same millisecond all get unique IDs."

Tests — no chrome polyfill, no jsdom

todos.js takes its storage argument (anything matching chrome.storage.local's get / set Promise shape). The test mock is ten lines:

function makeStorage(initial = {}) {
  const data = JSON.parse(JSON.stringify(initial));
  return {
    async get(keys) {
      const out = {};
      const arr = Array.isArray(keys) ? keys : [keys];
      for (const k of arr) if (k in data) out[k] = data[k];
      return out;
    },
    async set(items) { Object.assign(data, items); },
  };
}
Enter fullscreen mode Exit fullscreen mode

node --test runs 21 cases in 0.08 seconds. No @types/chrome, no sinon-chrome, no jsdom.

The reasoning: dependency-injecting storage covers ~90% of the LOC under unit tests; the remaining 10% (popup DOM operations, service-worker listener wiring) is smoke-tested manually in a real Chrome with "Load unpacked." Polyfills for the bits we don't actually use buy nothing.

Takeaways

  • Single top-level key in chrome.storage.local keeps onChanged listeners and writes simple. The "rewrite the whole thing per edit" cost is invisible at this data size.
  • URL key = origin + pathname, drop query and hash. chrome:// / about: / file:// pass through verbatim. Trailing slash on non-root paths gets normalised.
  • Badge stays in sync via three triggers (tabs.onActivated, tabs.onUpdated filtered for URL change OR status complete, storage.onChanged) all calling one helper.
  • Empty URL keys are pruned from storage so long-term use doesn't bloat the store.
  • Random IDs, not Date.now() — covered by an explicit unique-id test.
  • No chrome polyfill in tests — dependency-inject the storage shim, mock it in 10 lines, run under node --test.

Full source on GitHub. MIT licensed.

This is the second entry in the browser-extension series, after copy-as-md (entry #214, HTML→Markdown clipboard).

Top comments (0)