Modernize unclose for current Chrome extension APIs#24
Conversation
Agent-Logs-Url: https://github.com/wh5a/chrome/sessions/93b36160-7632-48fe-9bd2-80cc78fb3ad3 Co-authored-by: wh5a <81014+wh5a@users.noreply.github.com>
Agent-Logs-Url: https://github.com/wh5a/chrome/sessions/93b36160-7632-48fe-9bd2-80cc78fb3ad3 Co-authored-by: wh5a <81014+wh5a@users.noreply.github.com>
Agent-Logs-Url: https://github.com/wh5a/chrome/sessions/93b36160-7632-48fe-9bd2-80cc78fb3ad3 Co-authored-by: wh5a <81014+wh5a@users.noreply.github.com>
Agent-Logs-Url: https://github.com/wh5a/chrome/sessions/93b36160-7632-48fe-9bd2-80cc78fb3ad3 Co-authored-by: wh5a <81014+wh5a@users.noreply.github.com>
Agent-Logs-Url: https://github.com/wh5a/chrome/sessions/93b36160-7632-48fe-9bd2-80cc78fb3ad3 Co-authored-by: wh5a <81014+wh5a@users.noreply.github.com>
wh5a
left a comment
There was a problem hiding this comment.
Review: MV2 → MV3 modernization of unclose
Verdict: Changes Requested — solid migration overall, but introduces several concurrency bugs the synchronous localStorage code didn't have.
Critical (block merge)
1. Counter race in chrome.tabs.onRemoved — lost records
unclose/bg.js
var state = await storageGet({closeCount: 0, actualCount: 0, [tabKey]: null});
// ... await ...
var closeCount = parseInt(state.closeCount, 10) || 0;
updates["ClosedTab-" + closeCount] = tabId;
updates.closeCount = closeCount + 1;Two rapid tab closes both read closeCount=N, both write closeCount=N+1, and one's ClosedTab-N clobbers the other. Net effect: one closed tab silently vanishes from history and actualCount undercounts. The original localStorage["closeCount"]++ was atomic from JS's perspective; chrome.storage.session is not.
Fix: serialize writes through a queue, or use chrome.storage.session.get + set inside a mutex ((async () => { ... })() chained on a module-scoped promise).
2. actualCount decrement race in showUrl
unclose/popup.js
var state = await storageGet({actualCount: 0, ...});
await chrome.tabs.create(createProperties);
await clear(tabId);
await storageSet({actualCount: Math.max((parseInt(state.actualCount, 10) || 0) - 1, 0)});Between the storageGet and the storageSet, a tab close can fire onRemoved and increment actualCount. The popup's stale-read decrement then clobbers that increment. Same fix family as #1.
3. onInstalled wipes history on every update
unclose/init.js
chrome.runtime.onInstalled.addListener(async function() { await init(); });onInstalled fires for install, update, AND chrome_update. init() calls storageClear(). So any extension auto-update mid-session nukes the user's closed-tab history — even though chrome.storage.session would otherwise persist it through the SW lifecycle.
Fix:
chrome.runtime.onInstalled.addListener(async function(details) {
if (details.reason === 'install') await init();
else await ensureInitialized();
});4. Update/Remove ordering race for the same tab
Both chrome.tabs.onUpdated and chrome.tabs.onRemoved await ensureInitialized() before touching storage. If onUpdated is mid-await when onRemoved fires, onRemoved's read of TabList-<id> can run before onUpdated's write commits — closing a tab right after navigation may fail to record because URL isn't persisted yet. The sync localStorage version didn't have this gap.
Mitigation: same write-queue used for #1/#2. Or have onRemoved accept the tab from the removeInfo/cached state rather than re-reading.
Medium
5. Title-storage condition changed
unclose/bg.js
if(tab.title != null && currentUrl) updates["TabTitle-" + tabId] = tab.title;
else if (currentUrl) updates["TabTitle-" + tabId] = currentUrl;Previously: title stored whenever non-null (falling back to URL). Now: title only stored when a URL also exists. For brand-new tabs that receive a title before any URL update, the title is silently dropped. Minor, but a behavior shift not called out in the PR description.
6. onStartup + storageClear() is redundant
chrome.storage.session is automatically cleared when the browser restarts (that's the whole point of session scope). The deep clean on onStartup is a no-op. Harmless but misleading.
Minor / nits
setBadgeText()is called insideensureInitialized(), which runs on everyonUpdated/onRemoved/ popup open. Redundant write each time. Consider only calling it when counts actually change.storageGetAll()inloadTextreads every key on each popup open. For users with hundreds of accumulated entries this is wasteful, but unlikely to matter at typical scale.options_page: "popup.html"(manifest) means the same page is rendered both as a popup and as a full options tab.window.close()onactualCount == 0and afterreset()will try to close the options tab — pre-existing UX wart, not introduced here, but the MV3 changes don't fix it either.- Loose equality (
==/!=) preserved throughout. Not a regression, but worth tightening when touching the file.
Good things to call out
- Killing
innerHTMLforTabTitleand time labels in favor oftextContent+ real<b>nodes eliminates the half-brokenquote()(.replace("<", ...)only escaped the first occurrence — latent XSS via tab titles). Solid fix. - Stripping inline
onclick/onload/onselectstartforaddEventListeneris the right MV3 CSP move. parseInt(..., 10) || 0defensive parsing on counters is an improvement over the originallocalStorage["closeCount"]++(which string-incremented).- Splitting storage helpers into
storage.jsis clean; the helper layer makes the eventual race-fix localized to one file.
Recommended next step
Land a follow-up that serializes background writes (one shared promise chain for all storage.sets in bg.js) and gates onInstalled on details.reason === 'install'. Those two changes resolve #1–#3 and most of #4.
…rtup cleanup Agent-Logs-Url: https://github.com/wh5a/chrome/sessions/89b171b4-e2a4-4bc6-9b6e-b4407919ab0f Co-authored-by: wh5a <81014+wh5a@users.noreply.github.com>
…ker alive Agent-Logs-Url: https://github.com/wh5a/chrome/sessions/88e12e2b-d875-4235-bc17-b22a6f740199 Co-authored-by: wh5a <81014+wh5a@users.noreply.github.com>
Fix concurrency bugs in unclose MV3 migration
There was a problem hiding this comment.
Pull request overview
This PR modernizes the unclose Chrome extension to work on current Chrome by migrating it to Manifest V3, moving background logic into a service worker, and replacing legacy localStorage state with chrome.storage.session while keeping the closed-tab restore UX and behavior intact.
Changes:
- Migrates the extension to MV3 (
background.service_worker,action) and introduces a service-worker entrypoint. - Replaces
localStorageusage withchrome.storage.sessionvia a small shared storage helper layer. - Updates popup code to be MV3-CSP compatible (no inline HTML handlers) and avoids HTML insertion for titles.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| unclose/storage.js | Adds shared helpers for session-scoped storage get/set/remove/clear and tab key grouping. |
| unclose/init.js | Moves initialization/badge logic to async storage APIs; initializes state on install/startup. |
| unclose/bg.js | Updates background tab event handling to write tab metadata and closed-tab history into session storage; adds a write queue. |
| unclose/background.js | New MV3 service worker entrypoint that importScripts() the extension logic. |
| unclose/popup.js | Converts popup UI to async storage access, switches to textContent, and replaces HTML inline handlers with DOM listeners. |
| unclose/popup.html | Updates script loading order to include storage helpers and removes inline event attributes. |
| unclose/manifest.json | Migrates manifest to MV3, replaces browser_action with action, and adds storage permission. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @@ -93,14 +110,14 @@ function loadFavicon() { | |||
| imgs[i].src = imgs[i].alt; | |||
| } | |||
|
|
|||
| function loadContent() { | |||
| loadText(); | |||
| async function loadContent() { | |||
| // Re-read actualCount right before decrementing to minimise the race window | ||
| // with concurrent onRemoved increments. | ||
| var currentCount = parseInt(await storageGet("actualCount"), 10) || 0; | ||
| await storageSet({ | ||
| actualCount: Math.max(currentCount - 1, 0) |
| chrome.tabs.onUpdated.addListener(async function(tabId, changeInfo, tab) { | ||
| await enqueueWrite(async () => { | ||
| await ensureInitialized(); | ||
| var tabKey = "TabList-" + tabId; | ||
| var currentUrl = tab.url || await storageGet(tabKey); |
|
@copilot apply changes based on the comments in this thread |
Agent-Logs-Url: https://github.com/wh5a/chrome/sessions/9965d665-d2af-432a-abf7-e8d3f0e536ea Co-authored-by: wh5a <81014+wh5a@users.noreply.github.com>
Addressed in 2426930. The restore path now goes through the background write queue, initialization no longer rewrites the badge on every tab event, and the remaining follow-up thread fixes are included there as well. UI screenshot: https://github.com/user-attachments/assets/4687638b-6278-43e1-83f2-317579ccc591
Applied the thread changes in 2426930. That includes moving popup restore/decrement work into the background queue, removing the implicit global in Warning Firewall rules blocked me from connecting to one or more addresses (expand for details)I tried to connect to the following addresses, but was blocked by firewall rules:
If you need me to access, download, or install something from one of these locations, you can either:
|
|
@copilot Do we need storage? Can we just use an in-memory global object to maintain the tabs? |
Not safely in MV3. The popup and service worker do not share a JS heap, the popup is destroyed when it closes, and the service worker can be suspended between events, so a module-level object would lose history unpredictably during the same browser session. |
Uh oh!
There was an error while loading. Please reload this page.