惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Blog — PlanetScale
Blog — PlanetScale
B
Blog
A
About on SuperTechFans
大猫的无限游戏
大猫的无限游戏
爱范儿
爱范儿
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
H
Help Net Security
H
Hackread – Cybersecurity News, Data Breaches, AI and More
博客园 - 三生石上(FineUI控件)
有赞技术团队
有赞技术团队
酷 壳 – CoolShell
酷 壳 – CoolShell
WordPress大学
WordPress大学
IT之家
IT之家
D
Docker
Google DeepMind News
Google DeepMind News
罗磊的独立博客
T
The Blog of Author Tim Ferriss
aimingoo的专栏
aimingoo的专栏
博客园 - 叶小钗
Recent Announcements
Recent Announcements
阮一峰的网络日志
阮一峰的网络日志
D
DataBreaches.Net
博客园 - 司徒正美
Engineering at Meta
Engineering at Meta

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
A Per-URL TODO List Chrome Extension in 150 Lines — Singl...
SEN LLC · 2026-04-30 · via DEV Community

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).