fix(window): defer the repaint size nudge and skip it on macOS 26 (#10253)

The show/restore repaint jiggle mutated the window frame synchronously
inside AppKit's window-state dispatch. On macOS 26 (Tahoe), windows are
scene-backed and a frame update sent re-entrantly from scene-update
handling can self-deadlock the main thread in FrontBoardServices
(kernel-confirmed spindump: performAsyncAndWait on the callout queue the
main thread was draining).

- Defer the setSize nudge to a fresh event-loop turn so it never runs
  inside the show/restore dispatch.
- On Darwin 25+ (macOS 26) skip the nudge entirely and rely on
  webContents.invalidate(); the nudge works around a pre-Tahoe
  black-surface compositor bug.
This commit is contained in:
Brennan Benson
2026-07-23 18:28:52 -07:00
committed by GitHub
parent 14c99f56c1
commit 4569a4fe71
4 changed files with 106 additions and 8 deletions
+61 -2
View File
@@ -11,7 +11,8 @@ const {
notificationShowMock,
powerMonitorOnMock,
powerMonitorRemoveListenerMock,
isMock
isMock,
macosTahoeMock
} = vi.hoisted(() => {
const menuPopupMock = vi.fn()
const notificationShowMock = vi.fn()
@@ -27,7 +28,8 @@ const {
notificationShowMock,
powerMonitorOnMock: vi.fn(),
powerMonitorRemoveListenerMock: vi.fn(),
isMock: { dev: false }
isMock: { dev: false },
macosTahoeMock: { value: false }
}
})
@@ -49,6 +51,10 @@ vi.mock('@electron-toolkit/utils', () => ({
is: isMock
}))
vi.mock('./macos-tahoe-release', () => ({
isMacosTahoeOrNewer: vi.fn(() => macosTahoeMock.value)
}))
vi.mock('../app-icon', () => ({
getAppIconPath: vi.fn(() => 'icon')
}))
@@ -90,6 +96,7 @@ describe('createMainWindow', () => {
powerMonitorOnMock.mockReset()
powerMonitorRemoveListenerMock.mockReset()
isMock.dev = false
macosTahoeMock.value = false
vi.mocked(ipcMain.on).mockReset()
vi.mocked(ipcMain.removeListener).mockReset()
vi.mocked(ipcMain.handle).mockReset()
@@ -357,6 +364,10 @@ describe('createMainWindow', () => {
windowHandlers.get('restore')?.[0]?.()
expect(webContents.invalidate).toHaveBeenCalledTimes(2)
// Why: the size nudge must never run inside the show/restore dispatch itself.
expect(browserWindowInstance.setSize).not.toHaveBeenCalled()
vi.advanceTimersByTime(0)
expect(browserWindowInstance.setSize).toHaveBeenNthCalledWith(1, 1201, 800)
expect(browserWindowInstance.setSize).toHaveBeenCalledTimes(1)
@@ -377,6 +388,54 @@ describe('createMainWindow', () => {
expect(browserWindowInstance.setSize).toHaveBeenCalledTimes(setSizeCalls)
})
it('repaints without the size nudge on macOS 26+ where re-entrant frame updates can deadlock AppKit', () => {
vi.useFakeTimers()
macosTahoeMock.value = true
const windowHandlers = new Map<string, ((...args: any[]) => void)[]>()
const webContents = {
on: vi.fn(),
setZoomLevel: vi.fn(),
setBackgroundThrottling: vi.fn(),
invalidate: vi.fn(),
isDestroyed: vi.fn(() => false),
setWindowOpenHandler: vi.fn(),
send: vi.fn(),
isDevToolsOpened: vi.fn(),
openDevTools: vi.fn(),
closeDevTools: vi.fn()
}
const browserWindowInstance = {
webContents,
on: vi.fn((event: string, handler: (...args: any[]) => void) => {
const handlers = windowHandlers.get(event) ?? []
handlers.push(handler)
windowHandlers.set(event, handlers)
}),
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => false),
isFullScreen: vi.fn(() => false),
getSize: vi.fn(() => [1200, 800]),
setSize: vi.fn(),
maximize: vi.fn(),
show: vi.fn(),
loadFile: vi.fn(),
loadURL: vi.fn()
}
browserWindowMock.mockImplementation(function () {
return browserWindowInstance
})
withPlatform('darwin', () => createMainWindow(null))
windowHandlers.get('show')?.[0]?.()
expect(webContents.invalidate).toHaveBeenCalledTimes(1)
// Why: the delayed second repaint must also stay setSize-free on Tahoe.
vi.advanceTimersByTime(300)
expect(webContents.invalidate).toHaveBeenCalledTimes(2)
expect(browserWindowInstance.setSize).not.toHaveBeenCalled()
})
it('supports all minus key variants for terminal zoom out', () => {
const windowHandlers: Record<string, (...args: any[]) => void> = {}
const webContents = {
+18 -6
View File
@@ -48,6 +48,7 @@ import { resolveWindowCloseAction } from './window-close-decision'
import { rectHasVisibleAreaOnAnyDisplay } from './window-bounds-validation'
import { closeDashboardPopout } from './dashboard-popout-window'
import { installPrivilegedWindowNavigationPolicy } from './privileged-window-navigation'
import { isMacosTahoeOrNewer } from './macos-tahoe-release'
// Why: show/restore/resume can overlap before the size nudge resets; never capture the temporary width as the next baseline.
const activeRepaintJiggles = new WeakSet<BrowserWindow>()
@@ -62,15 +63,26 @@ function forceRepaint(window: BrowserWindow): void {
if (window.isMaximized() || window.isFullScreen() || activeRepaintJiggles.has(window)) {
return
}
// Why: macOS 26 scene-backed windows can deadlock the main thread on re-entrant frame updates; invalidate alone recovers the compositor there.
if (isMacosTahoeOrNewer()) {
return
}
activeRepaintJiggles.add(window)
const [width, height] = window.getSize()
window.setSize(width + 1, height)
// Why: show/restore fire from inside AppKit's window-state dispatch; mutating the frame there re-enters scene handling, so nudge on a fresh turn.
setTimeout(() => {
if (!window.isDestroyed()) {
window.setSize(width, height)
if (window.isDestroyed()) {
activeRepaintJiggles.delete(window)
return
}
activeRepaintJiggles.delete(window)
}, 32)
const [width, height] = window.getSize()
window.setSize(width + 1, height)
setTimeout(() => {
if (!window.isDestroyed()) {
window.setSize(width, height)
}
activeRepaintJiggles.delete(window)
}, 32)
}, 0)
}
function installMacosVisibilityRepaint(window: BrowserWindow): void {
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest'
import { isMacosTahoeOrNewer } from './macos-tahoe-release'
describe('isMacosTahoeOrNewer', () => {
it('detects Darwin 25+ (macOS 26) as Tahoe or newer', () => {
expect(isMacosTahoeOrNewer('25.5.0')).toBe(true)
expect(isMacosTahoeOrNewer('26.0.0')).toBe(true)
})
it('treats older Darwin releases as pre-Tahoe', () => {
expect(isMacosTahoeOrNewer('24.6.0')).toBe(false)
expect(isMacosTahoeOrNewer('23.0.0')).toBe(false)
})
it('treats unparseable releases as pre-Tahoe', () => {
expect(isMacosTahoeOrNewer('')).toBe(false)
expect(isMacosTahoeOrNewer('unknown')).toBe(false)
})
})
+8
View File
@@ -0,0 +1,8 @@
import os from 'node:os'
// Why: Darwin 25.x = macOS 26 (Tahoe), where AppKit windows are scene-backed and
// re-entrant frame updates can self-deadlock the main thread in FrontBoardServices.
export function isMacosTahoeOrNewer(darwinRelease: string = os.release()): boolean {
const major = Number.parseInt(darwinRelease, 10)
return Number.isFinite(major) && major >= 25
}