Skip to content

Modernize unclose for current Chrome extension APIs#24

Merged
wh5a merged 11 commits into
masterfrom
copilot/modernize-unclose-extension
May 12, 2026
Merged

Modernize unclose for current Chrome extension APIs#24
wh5a merged 11 commits into
masterfrom
copilot/modernize-unclose-extension

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented May 12, 2026

  • Review the new PR comments and confirm which requested changes are still outstanding
  • Inspect the current unclose MV3 implementation, recent commits, and available validation/CI state
  • Route restore/decrement mutations through the background write queue to eliminate popup count races
  • Remove implicit globals and reduce redundant initialization/badge churn in the MV3 codepath
  • Run targeted validation, manually verify the popup UI, capture a screenshot, and reply to the new comments

Copilot AI linked an issue May 12, 2026 that may be closed by this pull request
Copilot AI changed the title [WIP] Update unclose extension for latest Chrome compatibility Modernize unclose for current Chrome extension APIs May 12, 2026
Copilot AI requested a review from wh5a May 12, 2026 08:35
Copilot finished work on behalf of wh5a May 12, 2026 08:35
Copy link
Copy Markdown
Owner

@wh5a wh5a left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 inside ensureInitialized(), which runs on every onUpdated / onRemoved / popup open. Redundant write each time. Consider only calling it when counts actually change.
  • storageGetAll() in loadText reads 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() on actualCount == 0 and after reset() 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 innerHTML for TabTitle and time labels in favor of textContent + real <b> nodes eliminates the half-broken quote() (.replace("<", ...) only escaped the first occurrence — latent XSS via tab titles). Solid fix.
  • Stripping inline onclick/onload/onselectstart for addEventListener is the right MV3 CSP move.
  • parseInt(..., 10) || 0 defensive parsing on counters is an improvement over the original localStorage["closeCount"]++ (which string-incremented).
  • Splitting storage helpers into storage.js is 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.

Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 12, 2026

@wh5a I've opened a new pull request, #25, to work on those changes. Once the pull request is ready, I'll request review from you.

Copilot AI and others added 3 commits May 12, 2026 09:13
@wh5a wh5a requested a review from Copilot May 12, 2026 21:31
@wh5a wh5a marked this pull request as ready for review May 12, 2026 21:31
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 localStorage usage with chrome.storage.session via 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.

Comment thread unclose/popup.js Outdated
Comment on lines +109 to +113
@@ -93,14 +110,14 @@ function loadFavicon() {
imgs[i].src = imgs[i].alt;
}

function loadContent() {
loadText();
async function loadContent() {
Comment thread unclose/popup.js Outdated
Comment on lines +148 to +152
// 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)
Comment thread unclose/bg.js
Comment on lines +10 to +14
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);
@wh5a
Copy link
Copy Markdown
Owner

wh5a commented May 12, 2026

@copilot apply changes based on the comments in this thread

Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 12, 2026

Review: MV2 → MV3 modernization of unclose

Verdict: Changes Requested — solid migration overall, but introduces several concurrency bugs the synchronous localStorage code didn't have....

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

@copilot apply changes based on the comments in this thread

Applied the thread changes in 2426930. That includes moving popup restore/decrement work into the background queue, removing the implicit global in loadFavicon(), and memoizing initialization so tab updates stop churning badge/storage work. UI screenshot: https://github.com/user-attachments/assets/4687638b-6278-43e1-83f2-317579ccc591

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:

  • accounts.google.com
    • Triggering command: /proc/self/exe /proc/self/exe --type=utility --utility-sub-type=network.mojom.NetworkService --lang=en-US --service-sandbox-type=none --no-sandbox --use-angle=swiftshader-webgl --crashpad-handler-pid=5670 --enable-crash-reporter=, --noerrdialogs --user-data-dir=/tmp/unclose-shot2/profile --change-stack-guard-on-fork=enable --shared-files=v8_context_snapshot_data:100 --field-trial-handle=3,i,6563810844467830137,10988486708959654155,262144 --disable-features=PaintHolding --variations-seed-version --pseudonymization-salt-handle=7,i,13968013095520498681,1801955370013454140,4 --trace-process-track-uuid=3190708989122997041 (dns block)
    • Triggering command: /opt/google/chrome/chrome /usr/bin/google-chrome --headless=new --disable-gpu --no-sandbox --user-data-dir=/tmp/unclose-shot2/profile --window-size=520,420 --screenshot=/tmp/unclose-shot2/unclose-popup.png file:///home/REDACTED/work/chrome/chrome/unclose/popup.html (dns block)
  • clients2.google.com
    • Triggering command: /proc/self/exe /proc/self/exe --type=utility --utility-sub-type=network.mojom.NetworkService --lang=en-US --service-sandbox-type=none --no-sandbox --use-angle=swiftshader-webgl --crashpad-handler-pid=5670 --enable-crash-reporter=, --noerrdialogs --user-data-dir=/tmp/unclose-shot2/profile --change-stack-guard-on-fork=enable --shared-files=v8_context_snapshot_data:100 --field-trial-handle=3,i,6563810844467830137,10988486708959654155,262144 --disable-features=PaintHolding --variations-seed-version --pseudonymization-salt-handle=7,i,13968013095520498681,1801955370013454140,4 --trace-process-track-uuid=3190708989122997041 (dns block)
    • Triggering command: /opt/google/chrome/chrome /usr/bin/google-chrome --headless=new --disable-gpu --no-sandbox --user-data-dir=/tmp/unclose-shot2/profile --window-size=520,420 --screenshot=/tmp/unclose-shot2/unclose-popup.png file:///home/REDACTED/work/chrome/chrome/unclose/popup.html (dns block)
  • clientservices.googleapis.com
    • Triggering command: /proc/self/exe /proc/self/exe --type=utility --utility-sub-type=network.mojom.NetworkService --lang=en-US --service-sandbox-type=none --no-sandbox --use-angle=swiftshader-webgl --crashpad-handler-pid=5670 --enable-crash-reporter=, --noerrdialogs --user-data-dir=/tmp/unclose-shot2/profile --change-stack-guard-on-fork=enable --shared-files=v8_context_snapshot_data:100 --field-trial-handle=3,i,6563810844467830137,10988486708959654155,262144 --disable-features=PaintHolding --variations-seed-version --pseudonymization-salt-handle=7,i,13968013095520498681,1801955370013454140,4 --trace-process-track-uuid=3190708989122997041 (dns block)
    • Triggering command: /opt/google/chrome/chrome /usr/bin/google-chrome --headless=new --disable-gpu --no-sandbox --user-data-dir=/tmp/unclose-shot2/profile --window-size=520,420 --screenshot=/tmp/unclose-shot2/unclose-popup.png file:///home/REDACTED/work/chrome/chrome/unclose/popup.html (dns block)
  • redirector.gvt1.com
    • Triggering command: /proc/self/exe /proc/self/exe --type=utility --utility-sub-type=network.mojom.NetworkService --lang=en-US --service-sandbox-type=none --no-sandbox --use-angle=swiftshader-webgl --crashpad-handler-pid=5670 --enable-crash-reporter=, --noerrdialogs --user-data-dir=/tmp/unclose-shot2/profile --change-stack-guard-on-fork=enable --shared-files=v8_context_snapshot_data:100 --field-trial-handle=3,i,6563810844467830137,10988486708959654155,262144 --disable-features=PaintHolding --variations-seed-version --pseudonymization-salt-handle=7,i,13968013095520498681,1801955370013454140,4 --trace-process-track-uuid=3190708989122997041 (dns block)
    • Triggering command: /opt/google/chrome/chrome /usr/bin/google-chrome --headless=new --disable-gpu --no-sandbox --user-data-dir=/tmp/unclose-shot2/profile --window-size=520,420 --screenshot=/tmp/unclose-shot2/unclose-popup.png file:///home/REDACTED/work/chrome/chrome/unclose/popup.html (dns block)
  • safebrowsingohttpgateway.googleapis.com
    • Triggering command: /proc/self/exe /proc/self/exe --type=utility --utility-sub-type=network.mojom.NetworkService --lang=en-US --service-sandbox-type=none --no-sandbox --use-angle=swiftshader-webgl --crashpad-handler-pid=5670 --enable-crash-reporter=, --noerrdialogs --user-data-dir=/tmp/unclose-shot2/profile --change-stack-guard-on-fork=enable --shared-files=v8_context_snapshot_data:100 --field-trial-handle=3,i,6563810844467830137,10988486708959654155,262144 --disable-features=PaintHolding --variations-seed-version --pseudonymization-salt-handle=7,i,13968013095520498681,1801955370013454140,4 --trace-process-track-uuid=3190708989122997041 (dns block)
    • Triggering command: /opt/google/chrome/chrome /usr/bin/google-chrome --headless=new --disable-gpu --no-sandbox --user-data-dir=/tmp/unclose-shot2/profile --window-size=520,420 --screenshot=/tmp/unclose-shot2/unclose-popup.png file:///home/REDACTED/work/chrome/chrome/unclose/popup.html (dns block)
  • www.google.com
    • Triggering command: /proc/self/exe /proc/self/exe --type=utility --utility-sub-type=network.mojom.NetworkService --lang=en-US --service-sandbox-type=none --no-sandbox --use-angle=swiftshader-webgl --crashpad-handler-pid=5670 --enable-crash-reporter=, --noerrdialogs --user-data-dir=/tmp/unclose-shot2/profile --change-stack-guard-on-fork=enable --shared-files=v8_context_snapshot_data:100 --field-trial-handle=3,i,6563810844467830137,10988486708959654155,262144 --disable-features=PaintHolding --variations-seed-version --pseudonymization-salt-handle=7,i,13968013095520498681,1801955370013454140,4 --trace-process-track-uuid=3190708989122997041 (dns block)
    • Triggering command: /opt/google/chrome/chrome /usr/bin/google-chrome --headless=new --disable-gpu --no-sandbox --user-data-dir=/tmp/unclose-shot2/profile --window-size=520,420 --screenshot=/tmp/unclose-shot2/unclose-popup.png file:///home/REDACTED/work/chrome/chrome/unclose/popup.html (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

Copilot finished work on behalf of wh5a May 12, 2026 21:42
Copilot AI requested a review from wh5a May 12, 2026 21:42
@wh5a
Copy link
Copy Markdown
Owner

wh5a commented May 12, 2026

@copilot Do we need storage? Can we just use an in-memory global object to maintain the tabs?

Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 12, 2026

@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. chrome.storage.session gives us session-scoped state that survives popup close/SW restart without persisting across browser restarts, which matches the old behavior much better. UI screenshot: https://github.com/user-attachments/assets/4687638b-6278-43e1-83f2-317579ccc591

Copilot finished work on behalf of wh5a May 12, 2026 22:41
@wh5a wh5a merged commit 6eb3190 into master May 12, 2026
1 check passed
@wh5a wh5a deleted the copilot/modernize-unclose-extension branch May 12, 2026 22:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Modernize the unclose extension to run on latest Chrome

3 participants