Rehoboth Builds

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

  1. Make an extension with "permissions": ["scripting", "tabs"] and a host permission for a local test server, plus a cs.js that writes a counter onto document.documentElement.dataset so the page can see it ran.
  2. Open a page from that server in a tab.
  3. From an extension page, call registerContentScripts for that host.
  4. Read the counter in the tab you opened first, then open a second tab and read it there.
  5. Call executeScript on the first tab, twice, reading the counter each time.
  6. Reload the extension from the same folder (same ID), inject once more, and press a key.

3. What we measured

StepReading
Tab opened before registeringno copy of the script
Tab opened after registeringone copy (the registration itself works)
First tab after executeScriptone copy
Same tab, second executeScriptthe second copy sees the first copy’s globals
After reloading the extensionno new copy injected; getRegisteredContentScripts() returns an empty list
Inject again after the reloadthe new copy does not see the old copies’ globals
One key press after thatthree 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.