I hit a reproducible service-worker problem on Electron 41 that I don't
think has been named yet (it's distinct from #172, which is the Electron-40
worker-start regression reported fixed in 41). After digging in with Claude we
isolated the exact facet and worked around it in userland. Sharing the root
cause and the workaround in case it helps the password-manager / web-clipper
cases people keep running into.
Root cause (reproducible on Electron 41)
A chrome-extension:// page works fine as a top-level tab, but is cut off
from its own extension's service worker when loaded as a nested iframe inside
a regular web page (i.e. an extension's web_accessible_resource embedded in
an https page). Inside that nested extension frame:
navigator.serviceWorker.controller === null
navigator.serviceWorker.getRegistrations() === []
navigator.serviceWorker.ready never resolves (hangs)
The same page as a top-level tab has a live controller, one activated
registration, and ready resolves. Verified with a minimal test extension that
reports each hop to its parent: the window.postMessage + transferred
MessagePort do arrive in the nested iframe — only the final
iframe → service-worker hop is dead.
This breaks the common "bridge" pattern used by password managers and web
clippers:
web app (https) ──window.postMessage──► content script
content script ──iframe.postMessage──► extension iframe (web_accessible_resource)
extension iframe ──navigator.serviceWorker.ready.active.postMessage(msg, [port])──► service worker
The extension's SW never receives the port, so the web app's handshake times
out (in my case: a permanent "extension stopped, toggle it off and back on"
dialog). It works in Chrome, where the WAR iframe is a proper extension context
controlled by the SW — so this looks like an Electron process-model /
site-isolation gap in attaching the extension service worker to nested
extension subframes, rather than an extension bug or an electron-chrome-extensions
bug per se.
I couldn't find this filed as a dedicated Electron issue; #172 here is the
closest, but its symptom is the SW not starting (Electron 40, reported working
again in 41), which is a different problem.
Workaround (userland bridge, no Electron patch)
Since the frame can still reach the SW over chrome.runtime (which does work in
a nested extension frame), Claude bridged the missing navigator.serviceWorker
path onto chrome.runtime. Two halves, both injected via
contextBridge.executeInMainWorld from preloads registered on the session
(registerPreloadScript):
Frame half — only patches a chrome-extension:// document that is nested
(top !== self) and whose serviceWorker.controller is null (top-level pages
and any future Electron version that fixes the controller keep the native
implementation). It overrides navigator.serviceWorker.ready to resolve to a
minimal registration whose .active.postMessage(data, [port]) opens a
chrome.runtime.connect to the SW.
Service-worker half — on that private runtime port, it recreates local
MessageChannel pairs and dispatches a synthetic
new MessageEvent('message', { data, ports }) on the worker global, so the
extension's existing self.onmessage handler receives a real MessagePort
exactly as it expected.
The key detail that makes this possible: chrome.runtime cannot transfer
MessagePorts, so the ports never cross it. Each side builds its own local
MessageChannel and only the serializable message payloads are relayed over the
runtime port (one runtime connection = one postMessage call, carrying N
ports). Both halves stay inert for any other runtime connection.
This fully fixed the affected extension. Happy to put together a minimal repro
extension (a tiny MV3 extension whose WAR iframe reports each hop) if that would
help get this filed/fixed upstream in Electron itself.
Environment
- Electron 41.7.0 (Chromium 138), macOS arm64
electron-chrome-extensions 4.9.0
I hit a reproducible service-worker problem on Electron 41 that I don't
think has been named yet (it's distinct from #172, which is the Electron-40
worker-start regression reported fixed in 41). After digging in with Claude we
isolated the exact facet and worked around it in userland. Sharing the root
cause and the workaround in case it helps the password-manager / web-clipper
cases people keep running into.
Root cause (reproducible on Electron 41)
A
chrome-extension://page works fine as a top-level tab, but is cut offfrom its own extension's service worker when loaded as a nested iframe inside
a regular web page (i.e. an extension's
web_accessible_resourceembedded inan https page). Inside that nested extension frame:
navigator.serviceWorker.controller===nullnavigator.serviceWorker.getRegistrations()===[]navigator.serviceWorker.readynever resolves (hangs)The same page as a top-level tab has a live controller, one activated
registration, and
readyresolves. Verified with a minimal test extension thatreports each hop to its parent: the
window.postMessage+ transferredMessagePortdo arrive in the nested iframe — only the finaliframe → service-worker hop is dead.
This breaks the common "bridge" pattern used by password managers and web
clippers:
The extension's SW never receives the port, so the web app's handshake times
out (in my case: a permanent "extension stopped, toggle it off and back on"
dialog). It works in Chrome, where the WAR iframe is a proper extension context
controlled by the SW — so this looks like an Electron process-model /
site-isolation gap in attaching the extension service worker to nested
extension subframes, rather than an extension bug or an
electron-chrome-extensionsbug per se.
I couldn't find this filed as a dedicated Electron issue; #172 here is the
closest, but its symptom is the SW not starting (Electron 40, reported working
again in 41), which is a different problem.
Workaround (userland bridge, no Electron patch)
Since the frame can still reach the SW over
chrome.runtime(which does work ina nested extension frame), Claude bridged the missing
navigator.serviceWorkerpath onto
chrome.runtime. Two halves, both injected viacontextBridge.executeInMainWorldfrom preloads registered on the session(
registerPreloadScript):Frame half — only patches a
chrome-extension://document that is nested(
top !== self) and whoseserviceWorker.controllerisnull(top-level pagesand any future Electron version that fixes the controller keep the native
implementation). It overrides
navigator.serviceWorker.readyto resolve to aminimal registration whose
.active.postMessage(data, [port])opens achrome.runtime.connectto the SW.Service-worker half — on that private runtime port, it recreates local
MessageChannelpairs and dispatches a syntheticnew MessageEvent('message', { data, ports })on the worker global, so theextension's existing
self.onmessagehandler receives a realMessagePortexactly as it expected.
The key detail that makes this possible:
chrome.runtimecannot transferMessagePorts, so the ports never cross it. Each side builds its own local
MessageChanneland only the serializable message payloads are relayed over theruntime port (one runtime connection = one
postMessagecall, carrying Nports). Both halves stay inert for any other runtime connection.
This fully fixed the affected extension. Happy to put together a minimal repro
extension (a tiny MV3 extension whose WAR iframe reports each hop) if that would
help get this filed/fixed upstream in Electron itself.
Environment
electron-chrome-extensions4.9.0