The Electron view that stayed blank until you resized the window
I'm building Orchestra, a desktop browser automation app that embeds real Chromium so Playwright can drive it. Not an <iframe>, not a <webview>, but a WebContentsView — a second native Chromium surface that the OS composites on top of the app's HTML UI.
That design gives you real Chromium and real isolation. It also costs you one thing permanently: the native view is always on top. CSS z-index can't beat it. Open a modal in your app and it renders under the embedded browser unless you move the native view out of the way first.
So you hide the view when overlays open and restore it when they close.
Except sometimes, after closing a modal, the browser panel stayed blank. Not crashed. The page was still loaded, still running, still responding to the automation driving it. But visually, nothing.
And if you manually resized the app window, it fixed itself. Instantly. Every time.
This took weeks to isolate, because it turned out to be three independent bugs that only showed up together.
Layer 1: zero-size views lose their compositor surface
The obvious way to hide a native view is to set its bounds to zero.
view.setBounds({ x: 0, y: 0, width: 0, height: 0 })
Or detach it from the window. Both hide it fine. The problem is on restore.
After a period at zero size or detached, the view's compositor surface is gone. The setBounds call that's supposed to bring it back does nothing visible. The web contents are alive — you can screenshot them through CDP — but nothing paints. Until you resize the window and the compositor rebuilds everything.
The fix feels dumb: never actually reach zero. Park the view as a 1×1 pixel dot in the corner.
if (reqW <= 0 || reqH <= 0) {
view.setBounds({ x: Math.max(0, winW - 1), y: Math.max(0, winH - 1), width: 1, height: 1 })
return { ok: true }
}
A 1×1 view still has a live compositor surface. It's painting one pixel into the corner of the window, nobody notices, and when you restore it to real bounds there's actually a surface there to resize.
Things I tried before that: setVisible(false) — same blank on restore. Detach and reattach — same, worse. Various combinations of webContents.invalidate() — nothing.
Layer 2: long hides evict the frame
Park-at-1×1 fixed short hides. Then someone leaves a settings modal open for two minutes, closes it, and the panel is blank again.
Chromium doesn't keep composited frames around forever. A view that's been effectively invisible for a while gets its frame evicted. We disable backgroundThrottling on this view, which helps, but it isn't enough.
After a long hide, one setBounds call to the correct rectangle produces nothing. The size "changes" but no fresh frame gets submitted.
What works is making the restore a two-step size change across event loop turns. Set it one pixel short of the target height, then set the real bounds ~150ms later.
const wasParked = cur.width <= 1 || cur.height <= 1
if (wasParked) {
view.setBounds({ ...target, height: Math.max(1, target.height - 1) })
restoreTimer = setTimeout(() => {
const v = getBrowserView()
if (v) v.setBounds(target)
}, 150)
return { ok: true }
}
Two different sizes, two turns of the event loop, and Chromium treats it as a real resize and submits a fresh frame. One setBounds doesn't work. Two in the same tick doesn't work either.
This isn't documented anywhere. I found it by systematically comparing what a manual window resize does that my code doesn't. A manual resize is a stream of size changes across many frames. The minimum synthetic version of that turns out to be exactly two.
Layer 3: the React effect that ate the fix
Both compositor behaviors handled. The bug still came back. Only after closing a modal, only sometimes.
This was the worst phase, because the main process code was demonstrably correct and the view was demonstrably blank.
The actual cause was on the renderer side, in the React component that tracks the browser panel's DOM placeholder and mirrors its rectangle to the native view.
const lastBoundsRef = useRef(null) // dedup so we don't spam IPC with identical rects
const sendBounds = useCallback(() => {
if (modalOpen) { ipc.setBrowserBounds({ x: 0, y: 0, width: 0, height: 0 }); ... return }
const next = measurePlaceholder()
if (sameAs(lastBoundsRef.current, next)) return // <- the dedup
ipc.setBrowserBounds(next)
lastBoundsRef.current = next
}, [poppedOut, modalOpen])
useEffect(() => {
sendBounds()
const observer = new ResizeObserver(sendBounds)
// ...observe placeholder + ancestors, listen to window resize...
return () => {
observer.disconnect()
ipc.setBrowserBounds({ x: 0, y: 0, width: 0, height: 0 }) // hide on unmount
}
}, [sendBounds, poppedOut])
The interaction:
sendBounds is a useCallback that depends on modalOpen. The effect depends on sendBounds. So when a modal opens or closes, the callback's identity changes, the effect re-runs, and the cleanup runs first.
The cleanup was written for unmount. It zeroes the view's bounds. But it also runs on every modal transition, because the dependencies changed.
So on modal close: cleanup fires and zeroes the bounds, parking the view at 1×1. Then the new effect body calls sendBounds(), which measures the placeholder, gets the same rectangle as before the modal opened, compares it against lastBoundsRef, finds them equal, and returns without sending anything.
The dedup ref was doing exactly what it was written to do. It was reporting "we already sent these bounds." We hadn't. The view was a 1×1 dot in the corner and the one IPC message that would have restored it was deduplicated away.
Nothing would ever un-stick it, until a manual window resize changed the measurements, broke the equality check, and let one setBounds through.
That's why resizing fixed it. And why I spent weeks staring at the compositor.
The fix is one line. The cleanup has to update the dedup ref to match what it actually sent.
ipc.setBrowserBounds({ x: 0, y: 0, width: 0, height: 0 })
lastBoundsRef.current = { x: 0, y: 0, width: 0, height: 0 }
A cache invalidation bug that spent weeks pretending to be a compositor bug.
Two general lessons from this layer. React effect cleanups run on every dependency change, not just unmount — if your cleanup has side effects written for "we're going away," gate it, or make it consistent with whatever the effect body does next. And if you keep a "last sent value" cache, every code path that sends has to update it. Every one.
The debugging trap: the screenshots were lying
One thing made this take twice as long as it should have.
Orchestra has internal tooling that captures the app for diagnostics, and it composites the embedded browser's contents into the picture by asking the web contents for a frame. The web contents were fine. Only the native view's on-screen surface was blank.
So every automated capture showed a healthy, fully rendered browser panel, while the actual monitor showed nothing.
If your rendering pipeline has layers, your debugging tools have to observe the layer that's actually broken. The only trustworthy check was an OS-level capture of the physical screen. We now treat "screenshot via the app" and "screenshot of the screen" as two different measurements, because they are.
The rules we ended up with
- Never let a native view reach zero size or detach it if you intend to bring it back. Park it at 1×1 in a corner so the compositor surface stays alive.
- Restore in two steps across event loop turns — off by one pixel, then the real bounds ~150ms later. Long hides evict frames and a single resize won't reliably bring them back.
- Centralize hide and show. Feature components never touch view bounds directly. They flip one store flag and a single component derives the bounds. Every component that "just quickly hides the view" is a future copy of this bug. One of ours did exactly that and reproduced the entire saga in miniature.
- Audit effect cleanups for unmount-only assumptions, and keep dedup caches in sync on every send path.
- Verify at the layer that fails. Composited self-screenshots can't tell you anything about the physical screen.
None of this is in the Electron docs. Most of it is only discoverable empirically. If you're embedding a WebContentsView and hiding it behind overlays, take the recipe.
Orchestra is a desktop browser automation app. Free to download at orchestra-automation.com.