From b637ee0fc6831f5300a6c4418dfc3b3587b6890d Mon Sep 17 00:00:00 2001 From: Clark Vines <38430798+clarkvines@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:52:25 -0500 Subject: [PATCH] fix(desktop): keep UI scale across in-page route navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desktop is a HashRouter over one file:// document, so every route is a distinct URL to Chromium's per-URL zoom store. A route the user never zoomed on has no record at all and resolves to the host default (100%) — that is every fresh session and every never-visited settings tab. In-page navigation fires neither did-finish-load nor any window event, so nothing re-asserted the persisted level. The window dropped to 100% while the Appearance control kept reading the chosen scale, because the renderer only learns of zoom changes through 'hermes:zoom:changed', which never fired. Touching the setting sent a fresh apply, which is why it appeared to fix itself. Re-assert the persisted level on main-frame did-navigate-in-page. Verified on real Electron 40.10.2 / Chromium 144 (win32): a recordless hash route reports 100% at the event, so the existing drift-guard sees the drop and re-applies, and still no-ops when the route's record already matches. Fixes #48658 Fixes #38854 Fixes #79863 Co-authored-by: Brooklyn Nicholson --- apps/desktop/electron/main.ts | 17 +++++++------ apps/desktop/electron/zoom.test.ts | 29 ++++++++++++++++++++++ apps/desktop/electron/zoom.ts | 39 ++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 7 deletions(-) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index ca852ad820..e9ac3b25e4 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -6557,6 +6557,7 @@ function installPreviewShortcut(window) { import { applyZoomLevel, DEFAULT_ZOOM_LEVEL, + installZoomReassertOnNavigation, installZoomReassertOnWindowEvents, percentToZoomLevel, ZOOM_STEP, @@ -11202,13 +11203,15 @@ function wireCommonWindowHandlers(win, { zoom = true }: { zoom?: boolean } = {}) if (zoom) { installZoomShortcuts(win) // 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). - installZoomReassertOnWindowEvents(win, () => restorePersistedZoomLevel(win)) - win.webContents.on('did-finish-load', () => restorePersistedZoomLevel(win)) + // (Chromium can drop webContents zoom after these window transitions), on + // EVERY full load — not once, since crash recovery reloads and would + // outlive a spent `once` listener (#46429) — and after in-page navigation, + // where Chromium applies the target hash route's own per-URL zoom record + // (see installZoomReassertOnNavigation; #48658, #38854, #79863). + const reassertZoom = () => restorePersistedZoomLevel(win) + + installZoomReassertOnWindowEvents(win, reassertZoom) + installZoomReassertOnNavigation(win.webContents, reassertZoom) } installContextMenuBridge(win) diff --git a/apps/desktop/electron/zoom.test.ts b/apps/desktop/electron/zoom.test.ts index 461a7a8e59..9d973fc970 100644 --- a/apps/desktop/electron/zoom.test.ts +++ b/apps/desktop/electron/zoom.test.ts @@ -12,6 +12,7 @@ import { applyZoomLevel, clampZoomLevel, DEFAULT_ZOOM_LEVEL, + installZoomReassertOnNavigation, installZoomReassertOnWindowEvents, isDebouncedReassertEvent, percentToZoomLevel, @@ -292,6 +293,34 @@ test('installZoomReassertOnWindowEvents skips destroyed windows', () => { assert.equal(calls, 0) }) +test('installZoomReassertOnNavigation covers full loads and main-frame in-page routes', () => { + const handlers = new Map() + let destroyed = false + let calls = 0 + + const webContents = { + isDestroyed: () => destroyed, + on(event, listener) { + handlers.set(event, listener) + } + } + + installZoomReassertOnNavigation(webContents, () => { + calls += 1 + }) + + assert.deepEqual([...handlers.keys()], ['did-finish-load', 'did-navigate-in-page']) + + handlers.get('did-finish-load')() + handlers.get('did-navigate-in-page')({}, 'file:///app/index.html#/new', true) + handlers.get('did-navigate-in-page')({}, 'file:///app/frame.html#anchor', false) + assert.equal(calls, 2) + + destroyed = true + handlers.get('did-navigate-in-page')({}, 'file:///app/index.html#/session/next', true) + assert.equal(calls, 2) +}) + // Zoom-wiring contract: chat windows keep global UI zoom while fixed-size // helper windows opt out. Tested via the extracted config — no source-text regex. test('chat windows opt into zoom', () => { diff --git a/apps/desktop/electron/zoom.ts b/apps/desktop/electron/zoom.ts index 061d069964..434ba02b05 100644 --- a/apps/desktop/electron/zoom.ts +++ b/apps/desktop/electron/zoom.ts @@ -154,6 +154,45 @@ export function installZoomReassertOnWindowEvents(win, reassert, platform = proc } } +/** + * Chromium persists zoom PER URL, and a hash route is a distinct URL. Desktop + * is a HashRouter over one `file://index.html`, so every session/settings route + * carries its own `partition.per_host_zoom_levels` record, and an in-page + * navigation applies the target route's record over whatever the window is + * showing. A route the user never zoomed on has no record at all, so it + * resolves to the host default — level 0, i.e. 100%. + * + * In-page navigation fires neither `did-finish-load` nor any window event, so + * nothing re-asserted the persisted level: opening a fresh session dropped the + * window to 100% while the Appearance control kept reading the chosen scale (it + * only learns of changes through `hermes:zoom:changed`, which never fired — + * which is why touching the setting appeared to fix it). #48658, #38854, #79863. + * + * Verified on real Electron 40.10.2 / Chromium 144 (win32): at + * `did-navigate-in-page` the frame already reports the target route's level, so + * restorePersistedZoomLevel's drift-guard sees the drop and re-applies — and + * still no-ops when the route's record already matches. Subframe hash changes + * must not touch the chat window's UI scale. + */ +export function installZoomReassertOnNavigation(webContents, reassert) { + if (!webContents?.on) { + return + } + + const reassertIfAlive = () => { + if (!webContents.isDestroyed?.()) { + reassert() + } + } + + webContents.on('did-finish-load', reassertIfAlive) + webContents.on('did-navigate-in-page', (_event, _url, isMainFrame) => { + if (isMainFrame) { + reassertIfAlive() + } + }) +} + /** * Zoom-wiring decision per window kind. Chat windows (main + session + the HUD) * keep global UI zoom; the pet overlay and the Quick Entry composer opt out