The Electron view that stayed blank until you resized the window — Orchestra<br>Download free<br>← BlogThe Electron view that stayed blank until you resized the window<br>July 13, 2026electronchromiumwebcontentsviewreactdebugging<br>I'm building Orchestra, a desktop browser automation app that embeds real Chromium so Playwright can drive it. Not an , not a , 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) {<br>view.setBounds({ x: Math.max(0, winW - 1), y: Math.max(0, winH - 1), width: 1, height: 1 })<br>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<br>if (wasParked) {<br>view.setBounds({ ...target, height: Math.max(1, target.height - 1) })<br>restoreTimer = setTimeout(() => {<br>const v = getBrowserView()<br>if (v) v.setBounds(target)<br>}, 150)<br>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(() => {<br>if (modalOpen) { ipc.setBrowserBounds({ x: 0, y: 0, width: 0, height: 0 }); ... return }<br>const next = measurePlaceholder()<br>if (sameAs(lastBoundsRef.current, next)) return //<br>ipc.setBrowserBounds(next)<br>lastBoundsRef.current = next<br>}, [poppedOut, modalOpen])
useEffect(() => {<br>sendBounds()<br>const observer = new ResizeObserver(sendBounds)<br>// ...observe placeholder + ancestors, listen to window resize...<br>return () => {<br>observer.disconnect()<br>ipc.setBrowserBounds({ x: 0, y: 0, width: 0, height: 0 }) // hide on unmount<br>}, [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...