From a6423d565b698e6ac43d8eb2e4e42d49170e69e4 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:37:13 -0700 Subject: [PATCH] fix(macos): prevent stale UI surfaces after wake (#11226) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- src/main/window/createMainWindow.test.ts | 57 +--- src/main/window/createMainWindow.ts | 19 +- .../window/renderer-viewport-reflow.test.ts | 276 ------------------ src/main/window/renderer-viewport-reflow.ts | 107 ------- src/renderer/index.html | 2 +- src/renderer/popout.html | 2 +- src/renderer/src/App.tsx | 2 +- src/renderer/src/assets/main.css | 13 +- .../src/web/web-viewport-shell.test.ts | 21 +- 9 files changed, 47 insertions(+), 452 deletions(-) delete mode 100644 src/main/window/renderer-viewport-reflow.test.ts delete mode 100644 src/main/window/renderer-viewport-reflow.ts diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index 013711e9e38..fc38ca95e77 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -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 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 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 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', () => { diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index ffed4d013c2..519e1817051 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -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) } diff --git a/src/main/window/renderer-viewport-reflow.test.ts b/src/main/window/renderer-viewport-reflow.test.ts deleted file mode 100644 index a9c83595e51..00000000000 --- a/src/main/window/renderer-viewport-reflow.test.ts +++ /dev/null @@ -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 = {}): 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).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).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).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).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) - }) -}) diff --git a/src/main/window/renderer-viewport-reflow.ts b/src/main/window/renderer-viewport-reflow.ts deleted file mode 100644 index e4d7450229f..00000000000 --- a/src/main/window/renderer-viewport-reflow.ts +++ /dev/null @@ -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() - -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[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) -} diff --git a/src/renderer/index.html b/src/renderer/index.html index ba84d774e10..a481d0e1443 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -1,5 +1,5 @@ - + Orca diff --git a/src/renderer/popout.html b/src/renderer/popout.html index 944bcd23b30..a67accd3fc3 100644 --- a/src/renderer/popout.html +++ b/src/renderer/popout.html @@ -1,5 +1,5 @@ - + Orca Agent Dashboard diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 6ee3e79eaa9..26a434525a4 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -2193,7 +2193,7 @@ function App(): React.JSX.Element { return (
{ - 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('') + expect(popoutIndex).toContain('') + 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/) })