Content scripts you register at runtime never reach the tabs already open
chrome.scripting.registerContentScripts() applies to pages that load
after the call. Every tab the user already has open gets nothing — no script, no
error. If you register after an optional host permission is granted, that means the feature is
dead in exactly the tabs the user goes back to try it in. Inject into open tabs yourself with
executeScript, and expect that fix to create a second bug.
27 September 2026 · reproduced in headless Chrome 153.0.8010.36 with a two-file test extension; the fix is the one running in our own extension
1. What the user sees
They open your options page, press the button that asks for access to all sites, accept the prompt, and switch back to the tab they were reading. They press the shortcut. Nothing happens. There is no error in their console and none in yours, because nothing ran. The only repair is reloading each tab by hand, and nobody knows to do that — so the conclusion they reach is that the extension is broken, at the one moment they were paying the most attention to it.
The same symptom has a second entrance: after your extension updates, the copies already running in open tabs lose their connection to the extension, and nothing new is put in their place.
2. Reproduce it
- Make an extension with
"permissions": ["scripting", "tabs"]and a host permission for a local test server, plus acs.jsthat writes a counter ontodocument.documentElement.datasetso the page can see it ran. - Open a page from that server in a tab.
- From an extension page, call
registerContentScriptsfor that host. - Read the counter in the tab you opened first, then open a second tab and read it there.
- Call
executeScripton the first tab, twice, reading the counter each time. - Reload the extension from the same folder (same ID), inject once more, and press a key.
3. What we measured
| Step | Reading |
|---|---|
| Tab opened before registering | no copy of the script |
| Tab opened after registering | one copy (the registration itself works) |
First tab after executeScript | one copy |
Same tab, second executeScript | the second copy sees the first copy’s globals |
| After reloading the extension | no new copy injected; getRegisteredContentScripts() returns an empty list |
| Inject again after the reload | the new copy does not see the old copies’ globals |
| One key press after that | three listeners fire: orphan,orphan,live |
4. The fix, and the bug the fix creates
After registering, and again when runtime.onInstalled reports an update, put a copy
into every open web tab:
async function injectIntoOpenTabs() {
let tabs = [];
try { tabs = await chrome.tabs.query({}); } catch { return; }
for (const t of tabs) {
if (t.id == null || !/^https?:\/\//.test(t.url || '')) continue;
try {
await chrome.scripting.executeScript({
target: { tabId: t.id, allFrames: true }, files: ['content.js'] });
} catch {}
}
}
Each tab gets its own try: store pages and browser pages always refuse, and one
refusal should not leave the rest of the tabs without a script. No new permission is needed
— you are only here because the host permission was just granted.
Now a tab can hold two copies, and both have a capture-phase keydown listener on
window. stopPropagation() in the first does not stop a second listener on
the same node, so one press runs the action twice: close-tab closes two tabs. We ran the
extension’s own content script from before and after the fix through the same harness,
injected twice, one press of Alt+K: before the fix it sent
['tab.new', 'tab.new'], after it sent ['tab.new']. The fix is a hook
each copy leaves for the next one:
try { window.__kbdShortcutsTeardown?.(); } catch {}
// ... register listeners ...
window.__kbdShortcutsTeardown = () => {
window.removeEventListener('keydown', onKeyDown, true);
};
5. What that hook does not reach
The hook works because two copies injected by the same running extension share one isolated
world — the table shows the second copy seeing the first. After a reload that stops being
true: the new copy lands in a fresh world and cannot see the orphans, while the orphans’
listeners keep firing. They can no longer message the extension, so actions do not double, but
an orphan still holds the settings it had and can still call preventDefault() on
keys it thinks are yours. The orphan has to clean itself up:
function onKeyDown(e) {
if (!chrome.runtime?.id) { teardown(); return; } // orphaned: leave the page alone
// ...
}
That check has to come before anything that swallows the key.
How we know, and what we did not test
The table comes from a script that builds the test extension, serves a local page and drives
Chrome through the steps above; it records the browser version and date with every reading. The
before-and-after comparison runs the extension’s real content.js from the
commit before the fix and from today, not a rewrite of it.
What we did not test: a reload from the same folder is our stand-in for a store update, and
we have not measured whether a real update behaves the same way. Our extension’s own test
suite also has no assertion that injectIntoOpenTabs() reaches every open tab; that
half rests on reading the code and on the extension loading cleanly. The orphan check in section
5 is not in the shipped extension yet.
Where this came from
It came out of Custom Keyboard Shortcuts, a Chrome extension that binds key combinations to browser actions per site or everywhere. It asks for no host permission at install, which is exactly why it registers its content script at runtime and why this bug existed. It is free on the Chrome Web Store and keeps its settings on your machine.
The API and the code are the easy half. The half that costs days is the store: the slot cap on a new account, the listing text going read-only mid-review, the uninstall URL you cannot change after you ship. The ten that caught us — three of them in full, no signup.
This note was drafted with an AI model from our own bug log and a reproduction we ran in Chrome, and automated checks ran before it was published.
More from the same build
Every one of these is something we hit while shipping the two extensions on this site, written up from our own code and measurements.
- One failed settings request can leave a content script dead until the tab reloads
- The hidden attribute loses to display: flex, and a test that reads el.hidden will not notice
- Chrome Web Store API v1 to v2 — what actually changes
- Chrome Extension Ship Kit — everything between “it works” and “it is live”
- A new Chrome Web Store account can publish two extensions
- setUninstallURL ships inside the extension, so the page has to exist first