From c82280b114adb5e437f57a599fa543418b15da73 Mon Sep 17 00:00:00 2001 From: Michael Belousov Date: Sun, 15 Nov 2026 07:43:50 -0500 Subject: [PATCH] web: don't read a desktop window resize as a keyboard inset keyboardInset() captured the canvas's rect on every focusin and only cleared it when a keyboard closed. On a desktop no keyboard ever opens, so the rect went permanently stale. A window that shrank after that read as a keyboard covering the space between rest.bottom and the smaller viewport: the app got a phantom inset to lay out above, and the canvas's CSS height got pinned to the focusin-time pixel height. The page could then resize around it forever without the editor noticing the new space, until a resize that grew the window back past the stale rect. Gate the inset on a device that actually has a soft keyboard: the primary pointer is coarse and never hovers (a phone or tablet). There the behavior is unchanged -- Android's resizes-content pages, the VirtualKeyboard API path and iOS's visual viewport all still satisfy it. One edge: a touch device with a mouse connected has a fine primary pointer, so it loses the inset optimization; its canvas then shrinks with the keyboard as resizes-content intends, and the app lays out in the smaller canvas, which is what the inset path exists to avoid. Found in an embedder (the graphl IDE in an electron window): focus the editor, shrink the window vertically, then resize the embedder's panel -- the editor stayed at the focusin-time height. Verified fixed there; the same page on a touch profile still gets insets. --- web/fizzy.js | 15 +++++++++++---- web/tests/embed.test.mjs | 2 +- web/tests/keyboard-inset.test.mjs | 14 ++++++++++++++ 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/web/fizzy.js b/web/fizzy.js index efb669635..882a6bf57 100644 --- a/web/fizzy.js +++ b/web/fizzy.js @@ -142,15 +142,22 @@ export function keyboardInset(win, send, canvas = null, owns = () => true) { const vv = win.visualViewport; const viewport = win.document?.querySelector('meta[name="viewport"]')?.content ?? ""; const overlays = /(?:^|[,;])\s*interactive-widget\s*=\s*overlays-content\s*(?:[,;]|$)/i.test(viewport); + // Only phones and tablets have a soft keyboard. On a desktop, a window shrinking after + // focusin would otherwise read as one and pin the canvas height to the stale focusin rect. + const softKeyboard = win.matchMedia?.("(pointer: coarse) and (hover: none)").matches ?? true; // A resizing viewport and VK geometry can use different origins. Measure only the // visual viewport unless the page explicitly requests an overlay (or has no VV API). - const vk = overlays || !vv ? win.navigator?.virtualKeyboard : null; + const vk = softKeyboard && (overlays || !vv) ? win.navigator?.virtualKeyboard : null; + const keyboardTop = () => { + if (!softKeyboard) return Infinity; + if (!vk) return vv ? vv.height + vv.offsetTop : win.innerHeight; + const { y, height } = vk.boundingRect; + return height ? y ?? win.innerHeight - height : Infinity; + }; const update = () => { const current = rect(); const base = rest ?? current; - const edge = vk ? (vk.boundingRect.height ? vk.boundingRect.y ?? win.innerHeight - vk.boundingRect.height : Infinity) - : vv ? vv.height + vv.offsetTop : win.innerHeight; - const next = owns() ? Math.max(0, base.bottom - edge) : 0; + const next = owns() ? Math.max(0, base.bottom - keyboardTop()) : 0; // resizes-content pages shrink the CSS canvas as well. Keep the focused canvas's layout // height until the keyboard closes, so both dvui loops see the same backing-store size. if (canvas && next > 0 && current.height < base.height) { diff --git a/web/tests/embed.test.mjs b/web/tests/embed.test.mjs index abec757aa..5bf8b7f35 100644 --- a/web/tests/embed.test.mjs +++ b/web/tests/embed.test.mjs @@ -61,7 +61,7 @@ const document = Object.assign(new Target(), { const window = Object.assign(new Target(), { visualViewport: Object.assign(new Target(), { height: 100, offsetTop: 0 }), devicePixelRatio: 1, scrollX: 0, scrollY: 0, localStorage: new Map(), parent: null, - dispatchEvent() {}, matchMedia: () => ({ matches: false, addEventListener() {} }), + dispatchEvent() {}, matchMedia: (q) => ({ matches: q === "(pointer: coarse) and (hover: none)", addEventListener() {} }), }); window.parent = window; Object.assign(globalThis, { diff --git a/web/tests/keyboard-inset.test.mjs b/web/tests/keyboard-inset.test.mjs index 29114a54c..ba4aacfa8 100644 --- a/web/tests/keyboard-inset.test.mjs +++ b/web/tests/keyboard-inset.test.mjs @@ -14,6 +14,20 @@ class Target { fire(type) { this.listeners[type]?.(); } } +// A desktop has no soft keyboard: a window shrinking after focusin is not an inset. +{ + const vv = Object.assign(new Target(), { height: 800, offsetTop: 0 }); + const win = Object.assign(new Target(), { navigator: {}, innerHeight: 800, visualViewport: vv, matchMedia: () => ({ matches: false }) }); + const canvas = { style: { height: '100%' }, getBoundingClientRect: () => ({ bottom: 800, height: 800 }) }; + const sent = []; + keyboardInset(win, (m) => sent.push(m), canvas); + win.fire('focusin'); + vv.height = win.innerHeight = 500; + win.fire('resize'); + assert.deepEqual(sent, []); + assert.equal(canvas.style.height, '100%'); +} + // Chrome on Android: the VirtualKeyboard API, with the browser told not to resize or pan. { const vk = Object.assign(new Target(), { overlaysContent: false, boundingRect: { height: 0 } });