ag
← back
July 14, 2026Case study

Works in Chrome, breaks in Electron: bisecting a customer bug with a toggle harness

A single-file Electron harness that turned every difference between Chrome and a customer's wrapper into one toggle - and root-caused two independent bugs, one of which was a Chromium version regression no code could reveal.

ElectronChromiumWebfuseDebuggingReproductionBun

Works in Chrome, breaks in Electron: bisecting a customer bug with a toggle harness

A customer embedded Webfuse inside their own ElectronJS wrapper and hit two bugs that never appeared in plain Chrome. This is the diagnostic instrument I built to answer them: a single-directory Electron app where every environmental variable that could differ between "plain Chrome" (works) and "the customer's wrapper" (breaks) is a toggle, with timestamped logging of the service-worker, navigation, console, and renderer-crash lifecycle. It is not a product. It exists to produce a defensible answer for a support thread, one flipped variable at a time.

The problem

Two symptoms, reported only inside the Electron wrapper:

  1. Reload locked. Once a Webfuse session loaded, the page could not be refreshed. Even Electron's native View -> Reload did nothing.
  2. Grey screen. Certain sites - the customer's example was a public game site - rendered a grey screen with the session controls gone, while the customer's own site loaded fine.

"Works in Chrome, breaks in Electron" is a large search space: load scheme (https vs file:// vs custom protocol), session partition, webSecurity, custom-protocol privileges, Node integration flags, and the Electron/Chromium version itself. The naive move - read the description, guess a cause, advise the customer - fails here for a specific reason. The two symptoms had two independent root causes, and the second was not guessable from any code. It only appeared once the harness matched the customer's exact bundled Chromium version. Two plausible hypotheses were confirmed wrong only by trying to reproduce them.

The design

One main.js main process. Every suspect is an environment variable with a Chrome-like default, so a single run isolates exactly one variable against an otherwise identical baseline. package.json scripts name the interesting configurations. The investigation escalated fidelity in stages until the bug reproduced.

Toggles, not branches. Load mode, partition, webSecurity, protocol privilege, nodeIntegration, nodeIntegrationInSubFrames, sandbox, Electron version - each is one env var defaulting to the safe value. The alternative, a throwaway repro per hypothesis, loses the ability to A/B one flag against a fixed baseline. That A/B is the whole point of the instrument.

webPreferences: {
  session: ses,
  webSecurity: WEB_SECURITY,
  contextIsolation: true,
  nodeIntegration: NODE_INTEGRATION,
  nodeIntegrationInSubFrames: NODE_IN_SUBFRAMES,   // injects Node into every iframe
  sandbox: SANDBOX,
},

Reproduce before advising, and verify the fix by toggling it. An early, plausible hypothesis was that nodeIntegrationInSubFrames injected Node globals into the Webfuse subframe. I abandoned it when flipping the supposed fix on and off did not change the symptom. The global seen in the Webfuse frame was Webfuse's own webpack polyfill, present regardless. The harness's ability to A/B a flag is exactly what caught the false positive - a hypothesis that survives only because you never tested the off state is not a diagnosis.

To see whether Node globals actually reached the cross-origin Webfuse iframe - invisible to page-world JS - the probe walks the whole frame tree via WebFrameMain, which can execute across origins:

async function probeAllFrames(wc) {
  const walk = (f) => (f ? [f, ...f.frames.flatMap(walk)] : []);
  const frames = walk(wc.mainFrame);
  for (const f of frames) {
    const r = await f.executeJavaScript(
      `({g: typeof global, r: typeof require, m: typeof module, p: typeof process})`, true);
    const leaked = Object.entries(r).filter(([, v]) => v !== 'undefined').map(([k]) => k).join(',') || 'none';
    log('PROBE frame', `leaked:[${leaked}]  ${(f.url || '').slice(0, 70)}`);
    /* real Node globals (require/module/process) never leaked; only `global`, which is
       Webfuse's own webpack polyfill - present regardless of nodeIntegrationInSubFrames */
  }
}

Instrumentation reveals mechanism, not just failure. The reload lock was diagnosed from a negative signal: after a reload command, no did-start-navigation fired. That ruled out the service-worker theory - a SW intercept still starts navigation - and pointed at Electron's will-prevent-unload, which silently cancels navigation when the page has a beforeunload handler. Webfuse installs one. In Chrome that produces a "Leave site?" dialog; in Electron the default is to swallow the reload with no signal at all.

// Webfuse installs a beforeunload handler. In Chrome that produces a "Leave site?"
// dialog; in Electron the DEFAULT is to silently CANCEL the navigation (reload does
// nothing, no did-start fires) unless you handle will-prevent-unload.
wc.on('will-prevent-unload', (event) => {
  if (process.env.ALLOW_UNLOAD === '1') {
    log('!!! will-prevent-unload fired → ALLOW_UNLOAD=1, overriding to ALLOW the reload');
    event.preventDefault();               // let the reload/navigation proceed
  } else {
    log('!!! will-prevent-unload fired → Electron default = CANCEL navigation (reload swallowed)');
  }
});

Faithful architecture mirroring, escalated in steps. The first mode ran Webfuse as the top frame over https. No grey screen. So I added a localhost mode: a top-level page over a real secure origin embeds Webfuse in an iframe, matching the customer's "React app -> Webfuse iframe via magic link" exactly, so nodeIntegrationInSubFrames actually applies to the Webfuse frame. Still no repro. The last untested variable was the Electron version itself. A side-by-side Electron 31.3.1 install - Chromium 126, the customer's version - reproduced the grey screen that Electron 42 (Chromium 148) did not. bisect.sh runs the repro against any Electron release to pin where the fix landed:

# Usage: ./bisect.sh 37   (broken on 31/Chromium126, works on 42/Chromium148)
V="${1:?usage: ./bisect.sh <electron-version>}"
DIR=".ev/$V"
if [ ! -x "$DIR/node_modules/.bin/electron" ]; then
  printf '{"devDependencies":{"electron":"%s"},"trustedDependencies":["electron"]}\n' "$V" > "$DIR/package.json"
  ( cd "$DIR" && bun install )
  [ -d "$DIR/node_modules/electron/dist" ] || node "$DIR/node_modules/electron/install.js"
fi
LOAD_MODE=localhost NODE_INTEGRATION=1 NODE_IN_SUBFRAMES=1 SANDBOX=off ALLOW_UNLOAD=1 \
  "$DIR/node_modules/.bin/electron" .

A few ergonomics kept the loop tight. Magic links are minted fresh on every launch - no REST call, no mid-test expiry. A smoke path (AUTO_QUIT_MS) allows headless boot checks with no window left on screen. And a hand-rolled .env loader was needed because the bun-launched electron child did not inherit bun's auto-loaded .env:

function loadDotEnv() {
  const raw = fs.readFileSync(path.join(__dirname, '.env'), 'utf8');
  for (const line of raw.split('\n')) {
    const m = line.match(/^\s*([\w.]+)\s*=\s*(.*)\s*$/);
    if (!m || line.trim().startsWith('#')) continue;
    if (process.env[m[1]] === undefined) process.env[m[1]] = m[2].replace(/^['"]|['"]$/g, '');
  }
}

Results

  • Reload lock: root-caused to Electron's default will-prevent-unload behavior. One-line fix - event.preventDefault(). The customer confirmed it "works perfectly."
  • Grey screen: root-caused to a Chromium version bug - reproduced on Electron 31.3.1 / Chromium 126, gone on Electron 42 / Chromium 148. Fix: upgrade Electron. Confirmed by reproducing in both directions in the harness.
  • Two false hypotheses ruled out with evidence before landing on the real cause: a secure-context / service-worker failure (the customer's context was secure), and nodeIntegrationInSubFrames Node injection (disproven by the frame probe and a failed fix toggle).
  • Harness fidelity escalated through three stages - direct https, then localhost + iframe, then a matched Electron version - before the grey screen reproduced. The diagnostic was built and validated the same day.

The principle

When something works here but breaks there, turn every difference between the two environments into an independent toggle over one shared baseline, and refuse to name a cause until flipping that toggle both reproduces and removes the symptom. A hypothesis that survives only because you never tested the off state is not a diagnosis - it is a guess that hasn't been caught yet.