From 036b659527efc163e103af5044dadc4bca78ddf4 Mon Sep 17 00:00:00 2001 From: kosta Date: Sat, 18 Jul 2026 03:38:44 -0400 Subject: [PATCH] fix(desktop): preserve UI scale after resize --- apps/desktop/electron/main.ts | 6 +-- apps/desktop/electron/zoom.test.ts | 64 ++++++++++++++++++++++++++---- apps/desktop/electron/zoom.ts | 33 +++++++++++---- 3 files changed, 85 insertions(+), 18 deletions(-) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 3b6ab311b081..5a14aedfce58 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -7173,9 +7173,9 @@ function wireCommonWindowHandlers(win, { zoom = true }: { zoom?: boolean } = {}) if (zoom) { installZoomShortcuts(win) - // Re-apply persisted zoom on show/restore/cross-display move (Windows can - // drop webContents zoom after minimize or a monitor-scale change) and on - // EVERY full load — not once. The crash-recovery path calls + // Re-apply persisted zoom on show/restore/resize/cross-display move + // (Chromium can drop webContents zoom after these window transitions) and + // on EVERY full load — not once. The crash-recovery path calls // webContents.reload(), which fires did-finish-load again after a `once` // listener is spent, so zoom was silently lost on renderer crash // recovery and any in-place reload/navigation (#46429). diff --git a/apps/desktop/electron/zoom.test.ts b/apps/desktop/electron/zoom.test.ts index 7fb81299494e..d32456e92546 100644 --- a/apps/desktop/electron/zoom.test.ts +++ b/apps/desktop/electron/zoom.test.ts @@ -6,16 +6,17 @@ import assert from 'node:assert/strict' -import { test } from 'vitest' +import { test, vi } from 'vitest' import { applyZoomLevel, clampZoomLevel, installZoomReassertOnWindowEvents, percentToZoomLevel, - ZOOM_REASSERT_WINDOW_EVENTS, + ZOOM_RESIZE_REASSERT_DELAY_MS, ZOOM_STORAGE_KEY, zoomLevelToPercent, + zoomReassertWindowEvents, zoomWiringForWindowKind } from './zoom' @@ -64,7 +65,7 @@ test('extreme percentages clamp to the level bounds', () => { assert.equal(percentToZoomLevel(1_000_000), 9) }) -test('installZoomReassertOnWindowEvents wires show, restore, and cross-display moves', () => { +test('installZoomReassertOnWindowEvents wires show, restore, resize, and cross-display moves on macOS and Windows', () => { const handlers = new Map() const win = { @@ -75,15 +76,62 @@ test('installZoomReassertOnWindowEvents wires show, restore, and cross-display m } let calls = 0 - installZoomReassertOnWindowEvents(win, () => { - calls += 1 - }) + installZoomReassertOnWindowEvents( + win, + () => { + calls += 1 + }, + 'win32' + ) - assert.deepEqual([...handlers.keys()], [...ZOOM_REASSERT_WINDOW_EVENTS]) + assert.deepEqual([...handlers.keys()], zoomReassertWindowEvents('win32')) handlers.get('show')() handlers.get('restore')() + handlers.get('resized')() handlers.get('moved')() - assert.equal(calls, 3) + assert.equal(calls, 4) +}) + +test('installZoomReassertOnWindowEvents debounces Linux resize and move events at the trailing edge', () => { + vi.useFakeTimers() + + try { + const handlers = new Map() + let destroyed = false + + const win = { + isDestroyed: () => destroyed, + on(event, listener) { + handlers.set(event, listener) + } + } + + let calls = 0 + + installZoomReassertOnWindowEvents( + win, + () => { + calls += 1 + }, + 'linux' + ) + + assert.deepEqual([...handlers.keys()], zoomReassertWindowEvents('linux')) + handlers.get('resize')() + vi.advanceTimersByTime(ZOOM_RESIZE_REASSERT_DELAY_MS / 2) + handlers.get('move')() + vi.advanceTimersByTime(ZOOM_RESIZE_REASSERT_DELAY_MS / 2) + assert.equal(calls, 0) + vi.advanceTimersByTime(ZOOM_RESIZE_REASSERT_DELAY_MS / 2) + assert.equal(calls, 1) + + handlers.get('resize')() + destroyed = true + vi.advanceTimersByTime(ZOOM_RESIZE_REASSERT_DELAY_MS) + assert.equal(calls, 1) + } finally { + vi.useRealTimers() + } }) test('installZoomReassertOnWindowEvents skips destroyed windows', () => { diff --git a/apps/desktop/electron/zoom.ts b/apps/desktop/electron/zoom.ts index aa7f31bc240b..7d1d80d974ff 100644 --- a/apps/desktop/electron/zoom.ts +++ b/apps/desktop/electron/zoom.ts @@ -48,23 +48,42 @@ export function applyZoomLevel(webContents, level) { return clamped } -// Chromium on Windows can drop webContents zoom when a BrowserWindow is minimized -// and restored or crosses onto a monitor with different display scaling. Re-apply -// the persisted level after each completed lifecycle transition. -export const ZOOM_REASSERT_WINDOW_EVENTS = ['show', 'restore', 'moved'] +// Chromium can drop webContents zoom when a BrowserWindow is resized, minimized +// and restored, or crosses onto a monitor with different display scaling. macOS +// and Windows provide trailing `resized`/`moved` events; Linux only provides the +// noisy `resize`/`move` pair, so debounce those fallbacks before re-applying the +// persisted level. +export const ZOOM_RESIZE_REASSERT_DELAY_MS = 100 -export function installZoomReassertOnWindowEvents(win, reassert) { +export function zoomReassertWindowEvents(platform = process.platform) { + return platform === 'linux' ? ['show', 'restore', 'resize', 'move'] : ['show', 'restore', 'resized', 'moved'] +} + +export function installZoomReassertOnWindowEvents(win, reassert, platform = process.platform) { if (!win?.on) { return } - for (const event of ZOOM_REASSERT_WINDOW_EVENTS) { + let resizeTimer + + for (const event of zoomReassertWindowEvents(platform)) { win.on(event, () => { if (win.isDestroyed?.()) { return } - reassert() + if (event !== 'resize' && event !== 'move') { + reassert() + + return + } + + clearTimeout(resizeTimer) + resizeTimer = setTimeout(() => { + if (!win.isDestroyed?.()) { + reassert() + } + }, ZOOM_RESIZE_REASSERT_DELAY_MS) }) } }