fix(macos): prevent stale UI surfaces after wake (#11226)

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
Jinwoo Hong
2026-07-28 16:37:13 -07:00
committed by GitHub
co-authored by OrcaWin
parent 638c3ca5d9
commit a6423d565b
9 changed files with 47 additions and 452 deletions
+10 -47
View File
@@ -364,7 +364,7 @@ describe('createMainWindow', () => {
}
})
it('keeps main-window background throttling enabled while repainting macOS visibility transitions', () => {
it('keeps macOS background throttling enabled while repainting visibility transitions', () => {
vi.useFakeTimers()
const windowHandlers = new Map<string, ((...args: any[]) => void)[]>()
let windowSize: [number, number] = [1200, 800]
@@ -405,11 +405,8 @@ describe('createMainWindow', () => {
withPlatform('darwin', () => createMainWindow(null))
// Why: throttling-off pins visibilityState 'visible' and renders occluded
// windows at full rate; this guards against reintroducing it.
expect(webContents.setBackgroundThrottling).not.toHaveBeenCalledWith(false)
expect(webContents.setBackgroundThrottling).toHaveBeenCalledWith(true)
expect(webContents.setBackgroundThrottling).not.toHaveBeenCalledWith(false)
expect(windowHandlers.get('restore')).toHaveLength(1)
expect(windowHandlers.get('show')).toHaveLength(1)
expect(windowHandlers.get('focus')).toHaveLength(1)
@@ -493,8 +490,7 @@ describe('createMainWindow', () => {
expect(browserWindowInstance.setSize).not.toHaveBeenCalled()
expect(webContents.invalidate).not.toHaveBeenCalled()
// The genuine reveal (matching sender) runs the full repaint: invalidate + the size jiggle
// that recomputes the stale dvh layout — the recovery bare focus/invalidate misses.
// The genuine reveal runs the pre-Tahoe compositor jiggle that bare focus avoids.
revealHandler?.({ sender: webContents } as never)
expect(webContents.invalidate).toHaveBeenCalledTimes(1)
// Why: the nudge is deferred off the event dispatch turn.
@@ -535,9 +531,7 @@ describe('createMainWindow', () => {
send: vi.fn(),
isDevToolsOpened: vi.fn(),
openDevTools: vi.fn(),
closeDevTools: vi.fn(),
enableDeviceEmulation: vi.fn(),
disableDeviceEmulation: vi.fn()
closeDevTools: vi.fn()
}
const browserWindowInstance = {
webContents,
@@ -550,8 +544,6 @@ describe('createMainWindow', () => {
isMaximized: vi.fn(() => false),
isFullScreen: vi.fn(() => false),
getSize: vi.fn(() => [1200, 800]),
getContentSize: vi.fn(() => [1200, 800]),
getBounds: vi.fn(() => ({ x: 0, y: 0, width: 1200, height: 840 })),
setSize: vi.fn(),
maximize: vi.fn(),
show: vi.fn(),
@@ -571,21 +563,9 @@ describe('createMainWindow', () => {
vi.advanceTimersByTime(300)
expect(webContents.invalidate).toHaveBeenCalledTimes(2)
expect(browserWindowInstance.setSize).not.toHaveBeenCalled()
// Why (STA-2383): invalidate repaints but never reflows, so Tahoe still has to recompute the
// dvh root — via the emulated viewport, which leaves the deadlock-prone frame untouched.
expect(webContents.enableDeviceEmulation).toHaveBeenCalledWith({
screenPosition: 'desktop',
screenSize: { width: 0, height: 0 },
deviceScaleFactor: 2.25,
viewSize: { width: 1200, height: 800 },
scale: 1
})
expect(webContents.disableDeviceEmulation).toHaveBeenCalled()
expect(browserWindowInstance.setSize).not.toHaveBeenCalled()
})
it('still reflows a maximized macOS 26 window, which the size nudge had to skip', () => {
it('invalidates a maximized macOS 26 window without changing its frame', () => {
vi.useFakeTimers()
macosTahoeMock.value = true
const windowHandlers = new Map<string, ((...args: any[]) => void)[]>()
@@ -599,9 +579,7 @@ describe('createMainWindow', () => {
send: vi.fn(),
isDevToolsOpened: vi.fn(),
openDevTools: vi.fn(),
closeDevTools: vi.fn(),
enableDeviceEmulation: vi.fn(),
disableDeviceEmulation: vi.fn()
closeDevTools: vi.fn()
}
const browserWindowInstance = {
webContents,
@@ -611,13 +589,9 @@ describe('createMainWindow', () => {
windowHandlers.set(event, handlers)
}),
isDestroyed: vi.fn(() => false),
// Why: the maximized/fullscreen bail-out only ever protected setSize from un-maximizing
// the window; emulation leaves the frame alone, so the reflow must still happen here.
isMaximized: vi.fn(() => true),
isFullScreen: vi.fn(() => true),
getSize: vi.fn(() => [1200, 800]),
getContentSize: vi.fn(() => [1200, 800]),
getBounds: vi.fn(() => ({ x: 0, y: 0, width: 1200, height: 840 })),
setSize: vi.fn(),
maximize: vi.fn(),
show: vi.fn(),
@@ -633,12 +607,11 @@ describe('createMainWindow', () => {
windowHandlers.get('show')?.[0]?.()
vi.advanceTimersByTime(300)
expect(webContents.enableDeviceEmulation).toHaveBeenCalled()
expect(webContents.disableDeviceEmulation).toHaveBeenCalled()
expect(webContents.invalidate).toHaveBeenCalledTimes(2)
expect(browserWindowInstance.setSize).not.toHaveBeenCalled()
})
it('reflows without the size nudge when macOS 26 wakes from sleep', () => {
it('invalidates without frame or device emulation when macOS 26 wakes from sleep', () => {
vi.useFakeTimers()
macosTahoeMock.value = true
const windowHandlers = new Map<string, ((...args: any[]) => void)[]>()
@@ -667,8 +640,6 @@ describe('createMainWindow', () => {
isMaximized: vi.fn(() => false),
isFullScreen: vi.fn(() => false),
getSize: vi.fn(() => [1200, 800]),
getContentSize: vi.fn(() => [1200, 800]),
getBounds: vi.fn(() => ({ x: 0, y: 0, width: 1200, height: 840 })),
setSize: vi.fn(),
maximize: vi.fn(),
show: vi.fn(),
@@ -681,8 +652,6 @@ describe('createMainWindow', () => {
withPlatform('darwin', () => createMainWindow(null))
// Why: 'resume' is the other AppKit dispatch context implicated in the 109-minute freeze,
// so it must take the frame-free path too — not just show/restore.
const resumeHandler = powerMonitorOnMock.mock.calls.find(
([event]) => event === 'resume'
)?.[1] as (() => void) | undefined
@@ -692,14 +661,8 @@ describe('createMainWindow', () => {
expect(webContents.invalidate).toHaveBeenCalled()
vi.advanceTimersByTime(300)
expect(browserWindowInstance.setSize).not.toHaveBeenCalled()
expect(webContents.enableDeviceEmulation).toHaveBeenCalledWith({
screenPosition: 'desktop',
screenSize: { width: 0, height: 0 },
deviceScaleFactor: 2.25,
viewSize: { width: 1200, height: 800 },
scale: 1
})
expect(webContents.disableDeviceEmulation).toHaveBeenCalled()
expect(webContents.enableDeviceEmulation).not.toHaveBeenCalled()
expect(webContents.disableDeviceEmulation).not.toHaveBeenCalled()
})
it('supports all minus key variants for terminal zoom out', () => {
+7 -12
View File
@@ -49,7 +49,6 @@ import { rectHasVisibleAreaOnAnyDisplay } from './window-bounds-validation'
import { closeDashboardPopout } from './dashboard-popout-window'
import { installPrivilegedWindowNavigationPolicy } from './privileged-window-navigation'
import { isMacosTahoeOrNewer } from './macos-tahoe-release'
import { reflowRendererViewport } from './renderer-viewport-reflow'
import { registerPluginPanelNavigationGuard } from '../plugins/plugin-panel-navigation-guard'
// Why: show/restore/resume can overlap before the size nudge resets; never capture the temporary width as the next baseline.
@@ -62,12 +61,9 @@ function forceRepaint(window: BrowserWindow): void {
return
}
window.webContents.invalidate()
// Why: macOS 26 scene-backed windows deadlock the main thread on frame mutation, but invalidate
// alone never reflows the dvh root (STA-2383); emulation reflows without touching the frame.
// Runs before the maximized/fullscreen bail-out below, which only exists to protect setSize —
// emulation leaves those states intact, and a maximized window goes stale just the same.
// Why: macOS 26 scene-backed windows deadlock on frame mutation, and device emulation can
// strand the compositor after wake. The native shell no longer relies on dvh reflow.
if (isMacosTahoeOrNewer()) {
reflowRendererViewport(window)
return
}
if (window.isMaximized() || window.isFullScreen() || activeRepaintJiggles.has(window)) {
@@ -125,10 +121,8 @@ function installMacosVisibilityRepaint(window: BrowserWindow): void {
}
}
// Why (STA-2383): occlusion-uncover fires no restore/show, so the renderer relays its genuine
// hidden→visible reveal instead. Unlike a bare focus (every Cmd+Tab, window never hidden), this
// only fires when the window was actually occluded/throttled — exactly when the stale dvh layout
// stranded the bottom status bar off-screen — so the full repaint's size jiggle is warranted.
// Why: occlusion reveal can fire no restore/show, so preserve the renderer relay without
// trusting events from another window.
const onRendererRevealed = (event: Electron.IpcMainEvent): void => {
if (window.isDestroyed() || window.webContents.isDestroyed()) {
return
@@ -142,7 +136,7 @@ function installMacosVisibilityRepaint(window: BrowserWindow): void {
window.on('restore', repaintAfterVisibilityTransition)
window.on('show', repaintAfterVisibilityTransition)
// Why: occlusion-uncover fires no restore/show, only focus; invalidate only — the setSize jiggle would SIGWINCH every terminal on Cmd+Tab. The renderer-reveal relay above covers the stale-layout recovery that invalidate alone misses.
// Why: occlusion-uncover can fire only focus; invalidate without resizing terminals on Cmd+Tab.
window.on('focus', () => {
if (!window.isDestroyed() && !window.webContents.isDestroyed()) {
window.webContents.invalidate()
@@ -303,7 +297,8 @@ export function createMainWindow(
setTrustedUIRendererWebContentsId(rendererWebContentsId)
if (process.platform === 'darwin') {
// Why: throttle the main window while hidden (guests self-unthrottle); toggle only while visible or Chromium blanks the surface (electron#42378).
// Why: preserve hidden-window power savings; stable native sizing and frame-only invalidation
// make wake recovery independent of the throttled viewport.
mainWindow.webContents.setBackgroundThrottling(true)
installMacosVisibilityRepaint(mainWindow)
}
@@ -1,276 +0,0 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import type { BrowserWindow } from 'electron'
const scaleFactorMock = { value: 1 }
vi.mock('electron', () => ({
screen: { getDisplayMatching: vi.fn(() => ({ scaleFactor: scaleFactorMock.value })) }
}))
import {
clearCrashBreadcrumbsForTest,
getCrashBreadcrumbSnapshot
} from '../crash-reporting/crash-breadcrumb-store'
import {
reflowRendererViewport,
VIEWPORT_REFLOW_RESTORE_ATTEMPTS,
VIEWPORT_REFLOW_SETTLE_MS
} from './renderer-viewport-reflow'
function createWindow(overrides: Record<string, unknown> = {}): BrowserWindow {
const webContents = {
isDestroyed: vi.fn(() => false),
enableDeviceEmulation: vi.fn(),
disableDeviceEmulation: vi.fn()
}
return {
webContents,
isDestroyed: vi.fn(() => false),
getContentSize: vi.fn(() => [1200, 800]),
getBounds: vi.fn(() => ({ x: 0, y: 0, width: 1200, height: 840 })),
setSize: vi.fn(),
setBounds: vi.fn(),
...overrides
} as unknown as BrowserWindow
}
describe('reflowRendererViewport', () => {
beforeEach(() => {
vi.useFakeTimers()
scaleFactorMock.value = 1
clearCrashBreadcrumbsForTest()
})
afterEach(() => {
vi.useRealTimers()
})
it('never mutates the native window frame', () => {
const window = createWindow()
reflowRendererViewport(window)
vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS + 1)
expect(window.setSize).not.toHaveBeenCalled()
expect(window.setBounds).not.toHaveBeenCalled()
})
it('defers the emulation off the caller stack, then restores the real viewport', () => {
const window = createWindow()
reflowRendererViewport(window)
// Why: nothing may run while AppKit's callout frame is still on the stack.
expect(window.webContents.enableDeviceEmulation).not.toHaveBeenCalled()
vi.advanceTimersByTime(0)
expect(window.webContents.enableDeviceEmulation).toHaveBeenCalledWith({
screenPosition: 'desktop',
screenSize: { width: 0, height: 0 },
deviceScaleFactor: 1.25,
viewSize: { width: 1200, height: 800 },
scale: 1
})
expect(window.webContents.disableDeviceEmulation).not.toHaveBeenCalled()
vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS)
expect(window.webContents.disableDeviceEmulation).toHaveBeenCalledTimes(1)
})
// Why: a CSS-pixel delta re-grids terminals sitting on an xterm row boundary, so the emulated
// viewport must match the real one exactly — the scale factor is the only thing allowed to move.
it('emulates the real viewport size so no terminal re-grids mid-reflow', () => {
const window = createWindow()
reflowRendererViewport(window)
vi.advanceTimersByTime(0)
const [params] = (window.webContents.enableDeviceEmulation as ReturnType<typeof vi.fn>).mock
.calls[0] as [Electron.Parameters]
expect(params.viewSize).toEqual({ width: 1200, height: 800 })
expect(params.scale).toBe(1)
})
// Why: Blink applies screenSize/viewPosition ahead of the desktop branch, so overriding them
// moves screen.width and window.screenX for the whole hold — a context menu opened mid-reflow
// would land at the wrong coordinates. Only the scale factor may move.
it('leaves screen geometry and window position untouched while emulating', () => {
const window = createWindow()
reflowRendererViewport(window)
vi.advanceTimersByTime(0)
const [params] = (window.webContents.enableDeviceEmulation as ReturnType<typeof vi.fn>).mock
.calls[0] as [Electron.Parameters]
expect(params.screenSize).toEqual({ width: 0, height: 0 })
expect(params).not.toHaveProperty('viewPosition')
})
// Why: a constant scale factor would equal the real one on a 1.25x display and reflow nothing.
it('offsets from the display the window is actually on', () => {
scaleFactorMock.value = 1.25
const window = createWindow()
reflowRendererViewport(window)
vi.advanceTimersByTime(0)
const [params] = (window.webContents.enableDeviceEmulation as ReturnType<typeof vi.fn>).mock
.calls[0] as [Electron.Parameters]
expect(params.deviceScaleFactor).toBe(1.5)
})
// Why: a display reporting 0 would emulate 0.25 and shrink everything.
it('falls back to 1x when the display reports no scale factor', () => {
scaleFactorMock.value = 0
const window = createWindow()
reflowRendererViewport(window)
vi.advanceTimersByTime(0)
const [params] = (window.webContents.enableDeviceEmulation as ReturnType<typeof vi.fn>).mock
.calls[0] as [Electron.Parameters]
expect(params.deviceScaleFactor).toBe(1.25)
})
it('collapses a burst of reveals into one emulation cycle', () => {
const window = createWindow()
for (let i = 0; i < 5; i += 1) {
reflowRendererViewport(window)
}
vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS + 1)
expect(window.webContents.enableDeviceEmulation).toHaveBeenCalledTimes(1)
expect(window.webContents.disableDeviceEmulation).toHaveBeenCalledTimes(1)
})
it('re-arms once the cycle finishes', () => {
const window = createWindow()
reflowRendererViewport(window)
vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS + 1)
reflowRendererViewport(window)
vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS + 1)
expect(window.webContents.enableDeviceEmulation).toHaveBeenCalledTimes(2)
expect(window.webContents.disableDeviceEmulation).toHaveBeenCalledTimes(2)
})
it('skips a window destroyed before the deferred turn runs', () => {
const window = createWindow()
reflowRendererViewport(window)
vi.mocked(window.isDestroyed).mockReturnValue(true)
vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS + 1)
expect(window.webContents.enableDeviceEmulation).not.toHaveBeenCalled()
})
it('skips a webContents destroyed before the deferred turn runs', () => {
const window = createWindow()
reflowRendererViewport(window)
vi.mocked(window.webContents.isDestroyed).mockReturnValue(true)
vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS + 1)
expect(window.webContents.enableDeviceEmulation).not.toHaveBeenCalled()
})
it('does not leave emulation on when the window dies mid-cycle', () => {
const window = createWindow()
reflowRendererViewport(window)
vi.advanceTimersByTime(0)
expect(window.webContents.enableDeviceEmulation).toHaveBeenCalledTimes(1)
vi.mocked(window.isDestroyed).mockReturnValue(true)
expect(() => vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS)).not.toThrow()
expect(window.webContents.disableDeviceEmulation).not.toHaveBeenCalled()
// Why: a stuck latch would silently disable every later reflow for this window.
vi.mocked(window.isDestroyed).mockReturnValue(false)
reflowRendererViewport(window)
vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS + 1)
expect(window.webContents.enableDeviceEmulation).toHaveBeenCalledTimes(2)
})
it('recovers the latch when enabling emulation throws', () => {
const window = createWindow()
vi.mocked(window.webContents.enableDeviceEmulation).mockImplementationOnce(() => {
throw new Error('Object has been destroyed')
})
reflowRendererViewport(window)
expect(() => vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS + 1)).not.toThrow()
// Why: nothing was applied, so no restore should be attempted.
expect(window.webContents.disableDeviceEmulation).not.toHaveBeenCalled()
reflowRendererViewport(window)
vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS + 1)
expect(window.webContents.enableDeviceEmulation).toHaveBeenCalledTimes(2)
})
it('retries the restore rather than stranding a live renderer at the overshot viewport', () => {
const window = createWindow()
vi.mocked(window.webContents.disableDeviceEmulation).mockImplementationOnce(() => {
throw new Error('transient failure')
})
reflowRendererViewport(window)
expect(() => vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS + 1)).not.toThrow()
// Why: the webContents is still alive, so the emulated viewport is still applied — giving up
// here would leave the renderer stuck at the overshot height forever.
expect(window.webContents.disableDeviceEmulation).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS + 1)
expect(window.webContents.disableDeviceEmulation).toHaveBeenCalledTimes(2)
// Why: the retry succeeded, so the latch is free and later reveals still reflow.
reflowRendererViewport(window)
vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS + 1)
expect(window.webContents.enableDeviceEmulation).toHaveBeenCalledTimes(2)
})
it('holds the latch while a restore is still being retried', () => {
const window = createWindow()
vi.mocked(window.webContents.disableDeviceEmulation).mockImplementation(() => {
throw new Error('still failing')
})
reflowRendererViewport(window)
vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS + 1)
// Why: stacking a second emulation over an unrestored viewport would compound the overshoot.
reflowRendererViewport(window)
vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS + 1)
expect(window.webContents.enableDeviceEmulation).toHaveBeenCalledTimes(1)
})
it('stops retrying the restore and re-arms after the attempt budget', () => {
const window = createWindow()
vi.mocked(window.webContents.disableDeviceEmulation).mockImplementation(() => {
throw new Error('always fails')
})
reflowRendererViewport(window)
vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS * (VIEWPORT_REFLOW_RESTORE_ATTEMPTS + 4))
expect(window.webContents.disableDeviceEmulation).toHaveBeenCalledTimes(
VIEWPORT_REFLOW_RESTORE_ATTEMPTS + 1
)
// Why: an unbounded retry would pin the latch and silently kill every later reflow.
vi.mocked(window.webContents.disableDeviceEmulation).mockImplementation(() => undefined)
reflowRendererViewport(window)
vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS + 1)
expect(window.webContents.enableDeviceEmulation).toHaveBeenCalledTimes(2)
})
it('leaves evidence when it gives up with the viewport still emulated', () => {
const window = createWindow()
vi.mocked(window.webContents.disableDeviceEmulation).mockImplementation(() => {
throw new Error('always fails')
})
reflowRendererViewport(window)
vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS * (VIEWPORT_REFLOW_RESTORE_ATTEMPTS + 4))
expect(getCrashBreadcrumbSnapshot().map((crumb) => crumb.name)).toEqual([
'viewport_reflow_restore_failed'
])
})
it('records nothing when the restore succeeds', () => {
const window = createWindow()
reflowRendererViewport(window)
vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS * (VIEWPORT_REFLOW_RESTORE_ATTEMPTS + 4))
expect(getCrashBreadcrumbSnapshot()).toEqual([])
})
it('abandons the restore once the webContents is gone', () => {
const window = createWindow()
vi.mocked(window.webContents.disableDeviceEmulation).mockImplementation(() => {
throw new Error('always fails')
})
reflowRendererViewport(window)
vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS + 1)
expect(window.webContents.disableDeviceEmulation).toHaveBeenCalledTimes(1)
// Why: a destroyed renderer takes its emulated viewport with it; retrying is pointless.
vi.mocked(window.webContents.isDestroyed).mockReturnValue(true)
vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS * 5)
expect(window.webContents.disableDeviceEmulation).toHaveBeenCalledTimes(1)
})
})
-107
View File
@@ -1,107 +0,0 @@
import { screen } from 'electron'
import type { BrowserWindow } from 'electron'
import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store'
// Why: long enough for the emulated scale factor to reach the renderer and drive a relayout.
export const VIEWPORT_REFLOW_SETTLE_MS = 32
// Why: any delta re-runs layout; 0.25 stays clear of float-compare noise against the real factor.
const VIEWPORT_REFLOW_SCALE_DELTA = 0.25
// Why: a restore that keeps failing on a live webContents would loop forever; give up and
// release the latch so a later reveal can try a fresh cycle.
export const VIEWPORT_REFLOW_RESTORE_ATTEMPTS = 3
// Why: overlapping reveals must not stack emulation calls; the outer one owns the restore.
const activeViewportReflows = new WeakSet<BrowserWindow>()
function isWindowGone(window: BrowserWindow): boolean {
return window.isDestroyed() || window.webContents.isDestroyed()
}
/**
* Force the renderer to recompute its viewport without touching the native window frame.
*
* Why: `webContents.invalidate()` repaints but never reflows, so a stale `dvh` root keeps the
* status bar clipped off-screen (STA-2383). The frame jiggle that used to fix that mutates
* NSWindow, which self-deadlocks the main thread on macOS 26's scene-backed windows. Device
* emulation drives the same resize through the compositor instead — real reflow, no scene update.
*
* Why scale factor and not a +1px viewport: a one-pixel height delta changes the CSS box, so a
* terminal sitting just under an xterm row boundary gains a row, and the pane fit observer reads
* that transient grid as stable and forwards a real PTY resize — then reverses it on restore.
* Measured 3/54 window heights SIGWINCHing twice per reveal. A scale-factor delta re-runs layout
* with byte-identical CSS geometry: 0/54, with no WebGL atlas rebuild or context loss.
*/
export function reflowRendererViewport(window: BrowserWindow): void {
if (activeViewportReflows.has(window) || isWindowGone(window)) {
return
}
activeViewportReflows.add(window)
// Why: reveal/resume fire from inside AppKit's dispatch; start on a fresh turn so nothing
// native runs while that callout frame is still on the stack.
setTimeout(() => {
if (isWindowGone(window)) {
activeViewportReflows.delete(window)
return
}
let emulating = false
try {
const [width, height] = window.getContentSize()
// Why: the real viewport, unchanged — only the scale factor moves.
const viewSize = { width, height }
// Why: emulating the current factor is a no-op, so offset from this window's own display
// rather than a constant, which would match on a 1.25x screen and reflow nothing.
const realScaleFactor = screen.getDisplayMatching(window.getBounds()).scaleFactor || 1
// Why: Blink reads screenSize/viewPosition before the desktop branch, so 'desktop' does
// not make them inert despite the mobile-only docs. Passing the content size and 0,0
// moved screen.width and window.screenX for the 32ms hold, misplacing anything that
// translates screen coords (the BrowserPane context menu). Empty screenSize means "no
// override", and an omitted viewPosition stays nullopt so the real position survives --
// Electron's types require the key, but its converter only assigns when present.
window.webContents.enableDeviceEmulation({
screenPosition: 'desktop',
screenSize: { width: 0, height: 0 },
deviceScaleFactor: realScaleFactor + VIEWPORT_REFLOW_SCALE_DELTA,
viewSize,
scale: 1
} as Parameters<typeof window.webContents.enableDeviceEmulation>[0])
emulating = true
} catch {
// Why: a teardown race can destroy the webContents mid-call; nothing was applied.
}
if (!emulating) {
activeViewportReflows.delete(window)
return
}
// Why: emulation must always be undone — leaving it on strands the renderer at the wrong
// scale factor for the rest of the window's life.
restoreRealViewport(window, 0)
}, 0)
}
function restoreRealViewport(window: BrowserWindow, attempt: number): void {
setTimeout(() => {
if (isWindowGone(window)) {
// Why: the emulated viewport dies with the webContents, so there is nothing to restore.
activeViewportReflows.delete(window)
return
}
try {
window.webContents.disableDeviceEmulation()
activeViewportReflows.delete(window)
} catch {
// Why: the webContents is still alive, so the renderer is stuck at the emulated scale;
// keep retrying and hold the latch so no second cycle stacks on the unrestored state.
if (attempt >= VIEWPORT_REFLOW_RESTORE_ATTEMPTS) {
// Why: releasing the latch beats pinning it — a later reveal can still restore. But the
// renderer is stranded at the wrong scale factor until one happens, so leave evidence
// rather than let a permanently-emulated viewport look like a rendering bug.
recordCrashBreadcrumb('viewport_reflow_restore_failed', { attempts: attempt + 1 })
activeViewportReflows.delete(window)
return
}
restoreRealViewport(window, attempt + 1)
}
}, VIEWPORT_REFLOW_SETTLE_MS)
}
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html>
<html class="native-shell">
<head>
<meta charset="UTF-8" />
<title>Orca</title>
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html>
<html class="native-shell">
<head>
<meta charset="UTF-8" />
<title>Orca Agent Dashboard</title>
+1 -1
View File
@@ -2193,7 +2193,7 @@ function App(): React.JSX.Element {
return (
<div
ref={setAppRootNode}
className="flex flex-col h-dvh w-screen overflow-hidden"
className="app-layout"
style={
{
'--collapsed-sidebar-header-width': `${collapsedSidebarHeaderWidth}px`,
+9 -4
View File
@@ -450,10 +450,7 @@
margin: 0;
padding: 0;
overflow: hidden;
/* Why: dvh tracks the visible viewport so the web app shell isn't taller
than the screen when a mobile browser's URL bar is shown (100vh = the
large viewport, which forces a ~40-100px scroll). In Electron there is
no browser chrome, so dvh resolves identically to the window height. */
/* Why: mobile browser chrome changes the visible viewport while open. */
height: 100dvh;
font-family: var(
--app-font-family,
@@ -693,6 +690,14 @@
overflow: hidden;
}
/* Why: native windows have a stable content box and need no wake-time viewport reflow. */
html.native-shell,
html.native-shell body,
html.native-shell #root,
html.native-shell .app-layout {
height: 100%;
}
/* Tab-group split resize handle — wide hit area, visible center line. */
.tab-group-split-resize-handle {
flex-shrink: 0;
@@ -12,7 +12,7 @@ function cssBlock(css: string, selector: string): string {
}
describe('web viewport shell', () => {
it('uses dynamic viewport height for document and app shell containers', () => {
it('keeps dynamic viewport height as the web default', () => {
const css = readSource('src/renderer/src/assets/main.css')
for (const selector of ['body', '#root', '.app-layout']) {
@@ -22,14 +22,29 @@ describe('web viewport shell', () => {
}
})
it('uses a percentage height chain only for native Electron shells', () => {
const css = readSource('src/renderer/src/assets/main.css')
const nativeIndex = readSource('src/renderer/index.html')
const popoutIndex = readSource('src/renderer/popout.html')
const webIndex = readSource('src/renderer/web-index.html')
const appSource = readSource('src/renderer/src/App.tsx')
expect(css).toMatch(
/html\.native-shell,\s*html\.native-shell body,\s*html\.native-shell #root,\s*html\.native-shell \.app-layout\s*\{\s*height: 100%;\s*\}/
)
expect(nativeIndex).toContain('<html class="native-shell">')
expect(popoutIndex).toContain('<html class="native-shell">')
expect(webIndex).not.toContain('native-shell')
expect(appSource).toContain('className="app-layout"')
expect(appSource).not.toContain('h-dvh')
})
it('uses dynamic viewport Tailwind utilities for web shell entry points', () => {
const source = [
readSource('src/renderer/src/App.tsx'),
readSource('src/renderer/src/web/main.tsx'),
readSource('src/renderer/src/web/WebConnect.tsx')
].join('\n')
expect(source).toContain('h-dvh')
expect(source).toContain('min-h-dvh')
expect(source).not.toMatch(/\b(?:min-)?h-screen\b/)
})