DEV Community

Hiroshi Toyama
Hiroshi Toyama

Posted on

Chrome 126+ Broke My WXT Extension Dev Setup — Here's What Changed and How to Fix It

I spent a weekend debugging a Chrome extension dev environment that stopped working after a Chrome update. No error messages. The extension loaded — I could open its options page — but content scripts never ran, service workers never started, and the UI stayed unchanged.

This post is about three separate failure modes I hit, why each happens, and the minimal fix for each.

The Setup

The project uses WXT (a Chrome extension framework built on Vite) with a custom dev.sh script that:

  1. Starts the WXT dev server for hot reload
  2. Launches a dedicated Chrome instance with the extension loaded
  3. Exposes Chrome's remote debugging port for MCP/DevTools access

A clean dev-build → chrome-start → edit-reload loop. Or so it was.

Problem 1: --load-extension No Longer Starts Service Workers in Chrome 126+

What broke

Chrome has a flag --load-extension=/path/to/ext that loads an unpacked extension at startup. Before Chrome 126, this worked well for local development. After Chrome 126, the extension appears to load — chrome-extension://ID/options.html is accessible — but:

  • The background service worker never appears in Target.getTargets CDP results
  • Content scripts declared in manifest.content_scripts are not injected
  • The extension is not written to the Chrome profile's Secure Preferences

The last point is the key. With --load-extension, Chrome 126+ treats the extension as ephemeral. It's accessible as a filesystem resource but not actually "installed" in the profile, so Chrome's normal extension machinery (service worker lifecycle, content script injection) doesn't activate.

Why web-ext switched to Extensions.loadUnpacked

The web-ext project documented this in their issue tracker and switched away from --load-extension for Chrome 126+. Their new approach:

  1. Start Chrome with --remote-debugging-pipe and --enable-unsafe-extension-debugging
  2. Call the Extensions.loadUnpacked CDP command via the pipe

Extensions.loadUnpacked writes to Secure Preferences just like clicking "Load unpacked" in chrome://extensions/. Once written, the extension is a real installed extension.

Critical caveat: Extensions.loadUnpacked is only available via pipe-based CDP (--remote-debugging-pipe), not via WebSocket-based CDP (--remote-debugging-port). Connecting via port returns "Method not available." even with --enable-unsafe-extension-debugging.

The simpler fix

After trying the pipe approach, I discovered something: if you already have the extension registered in Secure Preferences from a previous pipe-based install, you can start Chrome normally (no --load-extension) with --enable-unsafe-extension-debugging and it loads from the profile automatically.

But there's an even simpler path: --load-extension + --enable-unsafe-extension-debugging together. Testing showed that when --enable-unsafe-extension-debugging is present, Chrome treats --load-extension extensions as real installs and injects content scripts:

"${CHROME_BINARY}" \
  --user-data-dir="${USER_DATA_DIR}" \
  --remote-debugging-port=9222 \
  --load-extension="${EXTENSION_DIR}" \
  --enable-unsafe-extension-debugging
Enter fullscreen mode Exit fullscreen mode

That's the entire fix for service worker / content script injection.

Problem 2: WXT Dev Server Exits Immediately When Backgrounded

What broke

The dev script ran WXT in the background:

npm run dev &
WXT_PID=$!
# ... wait for build, start Chrome ...
wait "$WXT_PID"
Enter fullscreen mode Exit fullscreen mode

WXT would build the extension, print "Load manually", and exit. The wait returned. The dev server was gone.

Why

WXT uses Node.js readline for its interactive keyboard shortcuts:

rl ??= readline.createInterface({
    input: process.stdin,
    // ...
})
Enter fullscreen mode Exit fullscreen mode

When npm run dev & backgrounds the process, process.stdin is connected to /dev/null. readline immediately gets EOF, emits close, and WXT exits.

This only manifests when WXT is backgrounded — running it in the foreground is fine. But backgrounding is necessary because you need to start Chrome after the build completes.

The fix

Feed a non-closing stream to the process's stdin:

npm run dev < <(tail -f /dev/null) &
Enter fullscreen mode Exit fullscreen mode

tail -f /dev/null follows an empty file indefinitely, never sending data and never closing. WXT's stdin stays open. readline never gets EOF. WXT keeps running.

Problem 3: web-ext Injects Flags That Break Google Login

What broke

WXT's runner (web-ext) was starting Chrome correctly — but Google account login was consistently logged out on every restart.

Why

web-ext uses chrome-launcher for Chrome startup. chrome-launcher's defaultFlags() includes:

'--disable-sync',
'--use-mock-keychain',
Enter fullscreen mode Exit fullscreen mode

--use-mock-keychain is the destructive one. On macOS, Chrome encrypts cookies using the system keychain. --use-mock-keychain substitutes a fake keychain with a different encryption key. Cookies encrypted with the real keychain cannot be decrypted with the mock one and vice versa.

Once Chrome writes cookies with the mock keychain, subsequent Chrome starts (without the flag) cannot read them. Login state is destroyed.

web-ext excludes --disable-extensions, --mute-audio, and --disable-component-update from chrome-launcher's defaults — but not --disable-sync or --use-mock-keychain.

The fix

Disable WXT's web-ext runner entirely and launch Chrome manually:

// wxt.config.ts
export default defineConfig({
  webExt: {
    disabled: true,
  },
})
Enter fullscreen mode Exit fullscreen mode

Then start Chrome from dev.sh with exactly the flags you need and nothing else.

Bonus: WXT Dev Mode Removes content_scripts from the Manifest

This one isn't a Chrome regression — it's WXT's intentional behavior that became a problem once service workers stopped starting.

In dev mode, WXT strips content_scripts from manifest.json and relies on the background service worker to register them dynamically via chrome.scripting.registerContentScripts(). The service worker connects to WXT's dev server and WXT sends reload commands.

When the service worker doesn't start (Problem 1), this entire chain breaks. Content scripts are never registered.

Fix: use WXT's build:manifestGenerated hook to add content_scripts back to the dev manifest:

hooks: {
  'build:manifestGenerated': (wxt, manifest) => {
    // Also strip the localhost CSP entry WXT adds for HMR — Chrome MV3
    // rejects http:// origins in extension_pages CSP.
    const csp = manifest.content_security_policy as Record<string, string> | undefined;
    if (csp?.extension_pages) {
      csp.extension_pages = csp.extension_pages
        .replace(/\s*http:\/\/localhost:[0-9]+/g, '')
        .trim();
    }

    if (wxt.config.command === 'serve' && !manifest.content_scripts?.length) {
      (manifest as Record<string, unknown>).content_scripts = [{
        matches: ['https://your-target-site.com/*'],
        run_at: 'document_end',
        js: ['content-scripts/content.js'],
      }];
    }
  },
},
Enter fullscreen mode Exit fullscreen mode

This also strips http://localhost:3000 from the extension_pages CSP. Chrome MV3 forbids HTTP origins in that directive; WXT adds it for Vite HMR, but it may silently break extension loading.

The Full Picture

Three independent changes colliding:

Problem Root Cause Fix
Service worker / content scripts not running Chrome 126+ changed --load-extension to not register extensions in profile Add --enable-unsafe-extension-debugging
WXT dev server exits immediately readline on backgrounded stdin gets EOF npm run dev < <(tail -f /dev/null) &
Google login lost on restart web-ext injects --use-mock-keychain Disable WXT runner, launch Chrome manually
Content scripts not injected in dev WXT removes content_scripts from dev manifest Restore via build:manifestGenerated hook

None of these produce meaningful error messages. The extension "loads" in the sense that its static pages are accessible. Everything else silently fails. The debugging path was: CDP Target.getTargets to check for service workers, Secure Preferences inspection to check if the extension was actually installed, and process-level stdin inspection to find the WXT exit cause.

The startup order and flags are what matter:

  1. Start the dev server in the background, feeding stdin from tail -f /dev/null so it stays alive
  2. Wait for the build to finish
  3. Wait for the dev server to initialize before launching Chrome (so the service worker can connect)
  4. Launch Chrome with both --load-extension and --enable-unsafe-extension-debugging
npm run dev < <(tail -f /dev/null) &
WXT_PID=$!

# wait for build → wait for dev server → launch Chrome
"${CHROME_BINARY}" \
  --user-data-dir="${USER_DATA_DIR}" \
  --remote-debugging-port="${DEBUG_PORT}" \
  --load-extension="${EXTENSION_DIR}" \
  --enable-unsafe-extension-debugging &

wait "$WXT_PID"
Enter fullscreen mode Exit fullscreen mode

Top comments (0)