fix(desktop): keep UI scale across in-page route navigation

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 <brooklyn.bb.nicholson@gmail.com>
This commit is contained in:
Clark Vines
2026-08-24 21:52:25 -05:00
committed by brooklyn!
parent 5400fb88e5
commit b637ee0fc6
3 changed files with 78 additions and 7 deletions
+10 -7
View File
@@ -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)
+29
View File
@@ -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', () => {
+39
View File
@@ -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