Rehoboth Builds

Your extension’s keyboard shortcut dies when focus is inside an iframe

The bug report is always the same sentence: “it works, but sometimes it does nothing.” There is no error, no failed key, no pattern the user can describe — because the variable is not which key or which site, it is which frame the caret happens to be in.

10 September 2026 · from the 1.2.0 fix in our own keyboard-shortcut extension

1. keydown goes to one frame, and your script is not in it

Two facts that are individually boring and jointly a bug. A content script registered without all_frames runs in the top document only — that is the default. And a keydown event is dispatched into the document of the focused frame; it does not propagate outward into the embedder.

So the moment the user clicks anything that lives in an iframe, your listener is in a document that is no longer receiving keys. In practice that is a large fraction of the web:

  • embedded video and audio players, which grab focus when clicked
  • document and code editors that put the editing surface in a frame
  • SaaS apps whose entire content pane is a frame inside a shell
  • comment widgets, checkout fields, chat and support bubbles

None of that shows up when you test on your own page, and it does not fail loudly: the key just falls through to the site. Users experience it as flakiness, which is the worst possible diagnostic signal, because “sometimes” makes people suspect timing.

2. The fix is one field — plus the half that gets skipped

If you register statically, it is "all_frames": true in the manifest. If you register at runtime — which you must if you ask for host permissions after install rather than up front — it is the same field on the registration:

await chrome.scripting.registerContentScripts([{
  id: CS_ID,
  matches: ['<all_urls>'],
  js: ['content.js'],
  runAt: 'document_start',
  allFrames: true
}]);

The first question everyone asks is whether the shortcut now fires once per frame. It does not: the browser delivers the keydown to exactly one frame, the focused one, so exactly one copy of your listener sees it. Being in every frame does not mean being called by every frame.

The half that gets skipped is what happens to people who already installed you. A dynamic registration is persisted; shipping a new version does not rewrite it, so an install that registered with allFrames: false keeps the old registration and stays broken until the user removes and reinstalls the extension. You have to migrate it yourself, on startup, in place:

if (existing.length) {
  if (existing[0].allFrames !== true) {
    await chrome.scripting.updateContentScripts([{ id: CS_ID, allFrames: true }]);
  }
  return;
}

Read getRegisteredContentScripts, compare, update only if it differs. Skipping this means the fix reaches new users and nobody else — and your existing users are exactly the population that filed the bug.

3. Now half your actions run against the wrong document

This is the part that makes the change bigger than one field. Your listener now runs inside the frame, so anything it does to document, window or history happens to the frame:

  • history.back() navigates the iframe — the embedded player goes back a step, the page does not move
  • window.scrollTo(0, 0) scrolls the frame’s own viewport, which the user often cannot even see
  • anything measured against document.body.scrollHeight measures the frame

The reflex is to hop up to the parent, and both ways of doing that are dead ends:

window.top.history.back();          // SecurityError on a cross-origin frame
window.parent.postMessage(msg, '*'); // nobody is listening — that page is not ours

postMessage is a cooperation protocol. It works when both sides are your code. Here the embedder is a stranger’s page that has never heard of your extension and will never answer. No amount of care with origins fixes that, and it is worth saying plainly because “use postMessage” is the standard answer to the general question and it is the wrong answer to this one.

The route that does exist is the one you already own: your frame talks to your service worker, and the service worker can execute in any frame of that tab. The message the browser delivers carries the sender’s frame id, and frameId === 0 means the top frame:

async function runNavInTopFrame(tabId, what) {
  await chrome.scripting.executeScript({
    target: { tabId, frameIds: [0] },
    func: (w) => {
      if (w === 'nav.back') history.back();
      else if (w === 'nav.top') window.scrollTo({ top: 0, behavior: 'smooth' });
    },
    args: [what]
  });
}

Two things about that func. It is serialised and run in the page, so it closes over nothing — every value it needs arrives through args, and a constant you reference from the outer scope is a ReferenceError at the far end, not a build error. And it needs no new permission: the host access that let your content script run in that frame is the same access this uses.

4. The reply has to depend on who asked, or the action happens twice

The top frame is still the common case, and there it is both cheaper and more reliable to do the navigation locally — no round trip through a service worker that may be asleep. So the same request has two correct answers, and the branch is on the sender:

const result = await HANDLERS[msg.action](msg.arg);   // e.g. { forward: 'nav.back' }
if (result?.forward && sender.frameId && sender.tab?.id != null) {
  await runNavInTopFrame(sender.tab.id, result.forward);
  sendResponse({ ok: true });                          // sub-frame: already done, do not repeat
} else {
  sendResponse({ ok: true, ...result });               // top frame: caller does it locally
}

The content script does the local step only when forward comes back. Return the same payload to both and a sub-frame press runs the action twice — two history entries per keypress, which is the sort of bug that looks like an unrelated site quirk.

One more thing that only bites here: a sub-frame can be removed by the page while your await is in flight, and then executeScript rejects. That is a normal event, not an error condition — catch it and drop the action rather than letting an unhandled rejection surface in the user’s console on someone else’s site.

5. What else changes once you are in every frame

Four smaller consequences, all of which we hit:

  • preventDefault() only covers the frame it happened in. You cannot stop the embedder’s own handler from a child frame. If your key collides with a site shortcut, the collision is now per-frame and you will get reports from one site and not another.
  • Your top-of-listener cost is now paid by every frame. Ad-heavy pages run dozens. Keep the early-out in front of everything — ours reads a cached settings object and returns before touching the DOM — and never do layout work before you know the combination is yours.
  • about:blank and srcdoc frames still count. They inherit the embedder’s origin, so your script runs there too. Anything that assumes a real location.href needs to tolerate that.
  • IME composition. With editors in frames you are now much likelier to see composition in progress; if (e.isComposing) return; stops you from stealing keys mid-word from anyone typing Japanese, Chinese or Korean.

The check that tells you whether you have this bug takes ten seconds and needs no tooling: open any page with an embedded player, click the player, press your shortcut. If nothing happens, this is the article.

How we know, and what we are not claiming

All of the above is the 1.2.0 change in our own keyboard-shortcut extension, shipped and on the store: the registration, the in-place migration for existing installs, the frame-id branch and the top-frame execution are the code that is running now.

What we are not claiming: we cannot tell you how many users were affected before the fix or how many stopped being affected after it. A store dashboard reports installs and uninstalls, not “the shortcut did nothing” — which is precisely why this class of bug deserves a pre-ship check rather than a metric. We also have not enumerated which embedded editors focus which way; the argument here is about frames in general, and one player is enough to reproduce it.

Where this came from

It came out of Custom Keyboard Shortcuts, a Chrome extension that binds key combinations to browser actions — new tab, close, reopen, pin, mute, copy the current URL — per site or everywhere. It is free on the Chrome Web Store, asks for no host permission at install time, and keeps its settings on your machine.

Our other extension, Highlight Reader, reads a page aloud and highlights each sentence as it speaks; it is also free, and a paid add-on for it — word-level highlighting and higher-quality voices — is described here. That add-on belongs to the reader, not to the shortcut extension.

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 registration that survives your update. The ten that caught us — three of them in full, no signup.