fix(mobile): keep the keyboard layout through a fold or rotation with the keyboard up

A shape change with the keyboard up re-baselined initialViewportHeight to the
SHRUNK visual height, so heightDiff was 0 and the settle event the OS fires at
the new width (or any later address-bar drift) satisfied the hide branch and
ran onKeyboardHide() with the keyboard still on screen: accessory bar hidden,
toolbar lift dropped, main's padding cleared. It could not recover, since no
further 150px drop re-arms the show branch against a baseline already sitting
at the shrunk height.

Baseline to window.innerHeight instead when the keyboard is up: the page sets
no interactive-widget, so the keyboard shrinks only the visual viewport and
the layout viewport stays the display's full height on both engines, the same
fact updateLayoutForKeyboard() relies on.

The vm harness now models the two heights separately (resizeTo takes an
optional layout height) and pins the fold flavour (626x590, 466x378, 466x378),
the rotation flavour (393x359, 852x150, 852x160) and the eventual close. All
three fail against the old line.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-09-14 15:57:08 +02:00
parent 8389423459
commit ef15768e5f
2 changed files with 86 additions and 8 deletions
+16 -2
View File
@@ -362,13 +362,27 @@ const KeyboardHandler = {
MobileDetection.updateAppHeight();
}
// Update baseline when keyboard is not visible — adapts to address bar
// Update baseline when keyboard is not visible: adapts to address bar
// state changes, orientation changes, and other viewport shifts. A shape
// change re-baselines even with the keyboard up (it may genuinely still be
// open, but its old baseline belongs to a display that is gone), and still
// writes --app-height below so the keyboard-open sizing follows the new
// display.
if (shapeChanged || !this.keyboardVisible) {
//
// ⚠️ With the keyboard up, the new baseline must be the KEYBOARD-FREE
// height of the display the device moved to, which is window.innerHeight
// (the layout viewport; the page sets no interactive-widget, so the
// keyboard shrinks only the visual viewport on both engines, the same
// fact updateLayoutForKeyboard() relies on). Baselining to the SHRUNK
// visual height made heightDiff 0, so the very next same-width resize
// (the settle event the OS animation produces, or any address-bar drift)
// satisfied the hide branch and tore the keyboard layout down with the
// keyboard still on screen, and it could not recover: no further 150px
// drop can re-arm the show branch against a baseline that already sits
// at the shrunk height.
if (shapeChanged) {
this.initialViewportHeight = this.keyboardVisible ? window.innerHeight : currentHeight;
} else if (!this.keyboardVisible) {
this.initialViewportHeight = currentHeight;
}
if (this.keyboardVisible) {
+70 -6
View File
@@ -42,11 +42,18 @@ const KEYBOARD_HEIGHT = 300;
* Load mobile-handlers.js against a fake DOM and return its KeyboardHandler
* plus the mutable viewport it reads.
*
* Two heights are modelled, because the handler reads both: `visualViewport`
* is what the keyboard shrinks, and `window.innerHeight` is the layout
* viewport, which stays the display's full height while the keyboard is up
* (the page sets no interactive-widget, so the default resizes-visual mode
* holds on both engines). resizeTo() ties them unless a test says otherwise.
*
* `const KeyboardHandler = {...}` is a lexical binding that does not survive to
* a second `vm.runInContext`, so the export is appended to the SAME script.
*/
function loadHandler(start: { width: number; height: number }) {
const viewport = { ...start, offsetTop: 0, addEventListener: () => {}, removeEventListener: () => {} };
const layout = { height: start.height };
const bodyClasses = new Set<string>();
const appHeight: string[] = [];
@@ -59,7 +66,7 @@ function loadHandler(start: { width: number; height: number }) {
return viewport.width;
},
get innerHeight() {
return viewport.height;
return layout.height;
},
visualViewport: viewport,
addEventListener: () => {},
@@ -94,10 +101,16 @@ function loadHandler(start: { width: number; height: number }) {
const handler = (context as unknown as { __KH: Handler }).__KH;
handler.init();
/** Move the viewport and fire the resize the browser would fire. */
const resizeTo = (width: number, height: number) => {
/**
* Move the visual viewport and fire the resize the browser would fire.
* `layoutHeight` is window.innerHeight; pass the display's full height to
* model a keyboard that is up (the visual viewport shrunk, the layout one
* not), and leave it out for a plain resize, where the two agree.
*/
const resizeTo = (width: number, height: number, layoutHeight = height) => {
viewport.width = width;
viewport.height = height;
layout.height = layoutHeight;
handler.handleViewportResize();
};
@@ -205,12 +218,63 @@ describe('handleViewportResize: width changes are the device changing shape', ()
// one that is gone (updateAppHeight() bails while the keyboard is up).
const { handler, resizeTo, appHeight } = loadHandler(DUO_INNER);
resizeTo(DUO_INNER.width, DUO_INNER.height - KEYBOARD_HEIGHT);
resizeTo(DUO_INNER.width, DUO_INNER.height - KEYBOARD_HEIGHT, DUO_INNER.height);
expect(handler.keyboardVisible).toBe(true);
resizeTo(DUO_OUTER.width, DUO_OUTER.height - KEYBOARD_HEIGHT);
resizeTo(DUO_OUTER.width, DUO_OUTER.height - KEYBOARD_HEIGHT, DUO_OUTER.height);
expect(appHeight.at(-1)).toBe(`${DUO_OUTER.height - KEYBOARD_HEIGHT}px`);
expect(handler.initialViewportHeight).toBe(DUO_OUTER.height - KEYBOARD_HEIGHT);
// The baseline is the new display's KEYBOARD-FREE height (window.innerHeight),
// never the shrunk visual height, or the next resize reads as the keyboard
// closing (see the two tests below).
expect(handler.initialViewportHeight).toBe(DUO_OUTER.height);
});
it('survives the settle event that follows a fold with the keyboard up', () => {
// The OS animates a shape change, so the browser fires one resize that
// changes the width and then at least one more at the settled width (see
// _scheduleViewportSettle). Baselining the first to the SHRUNK visual
// height made heightDiff 0 on the second, which satisfied the hide branch
// and tore the keyboard layout down with the keyboard still on screen, and
// nothing could re-arm the show branch against that baseline.
const { handler, resizeTo, bodyClasses } = loadHandler(DUO_INNER);
resizeTo(DUO_INNER.width, DUO_INNER.height - KEYBOARD_HEIGHT, DUO_INNER.height);
resizeTo(DUO_OUTER.width, DUO_OUTER.height - KEYBOARD_HEIGHT, DUO_OUTER.height);
resizeTo(DUO_OUTER.width, DUO_OUTER.height - KEYBOARD_HEIGHT, DUO_OUTER.height);
expect(handler.keyboardVisible).toBe(true);
expect(bodyClasses.has('keyboard-visible')).toBe(true);
expect(handler.initialViewportHeight).toBe(DUO_OUTER.height);
});
it('survives the settle event that follows a rotation with the keyboard up', () => {
// Same latch on a plain phone: portrait 393x659, keyboard up (visual 359),
// rotate to landscape 852x393 with the keyboard still up (visual 150),
// then the settle event at 160. The old baseline of 150 read the 160 as
// the keyboard closing.
const { handler, resizeTo, bodyClasses } = loadHandler({ width: 393, height: 659 });
resizeTo(393, 359, 659);
expect(handler.keyboardVisible).toBe(true);
resizeTo(852, 150, 393);
resizeTo(852, 160, 393);
expect(handler.keyboardVisible).toBe(true);
expect(bodyClasses.has('keyboard-visible')).toBe(true);
expect(handler.initialViewportHeight).toBe(393);
});
it('still detects the keyboard closing after a fold with it up', () => {
// The keyboard-free baseline is what makes the eventual close visible:
// dismissing it returns the visual viewport to the display height.
const { handler, resizeTo, bodyClasses } = loadHandler(DUO_INNER);
resizeTo(DUO_INNER.width, DUO_INNER.height - KEYBOARD_HEIGHT, DUO_INNER.height);
resizeTo(DUO_OUTER.width, DUO_OUTER.height - KEYBOARD_HEIGHT, DUO_OUTER.height);
resizeTo(DUO_OUTER.width, DUO_OUTER.height);
expect(handler.keyboardVisible).toBe(false);
expect(bodyClasses.has('keyboard-visible')).toBe(false);
});
});