FN-6362: reset mobile keyboard restore metrics

Reset mobile keyboard restore sampling so collapsed viewports clear stale keyboard-open metrics.

- Add a restore-specific sampling path for visibilitychange/pageshow that can reset the viewport baseline and bypass the impossible-sample hold once.
- Preserve the existing in-session impossible-sample guard and tail polling behavior for normal focus/resize updates.
- Cover iOS restore, stale offset drift, genuinely open restored keyboards, and Android-style shrink reset cases.
- Document the mobile keyboard restore stale viewport fix for future UI debugging.

Files changed:
 .../mobile-keyboard-restore-stale-viewport.md      |  59 ++++++++
 .../app/hooks/__tests__/useMobileKeyboard.test.ts  | 160 +++++++++++++++++++++
 packages/dashboard/app/hooks/useMobileKeyboard.ts  |  75 +++++++---
 3 files changed, 275 insertions(+), 19 deletions(-)

Fusion-Task-Id: FN-6362

Fusion-Task-Lineage: 3919d892-b2d5-419a-abeb-84ac298ca2d5
This commit is contained in:
gsxdsm
2026-06-13 10:14:11 -07:00
parent 1fddc7ace7
commit fb102a86d5
3 changed files with 279 additions and 23 deletions

View File

@@ -0,0 +1,59 @@
---
title: "Mobile keyboard restore stale viewport reset"
date: 2026-06-13
category: ui-bugs
module: packages/dashboard/app/hooks/useMobileKeyboard
problem_type: ui_bug
component: frontend_mobile_layout
applies_when: "A mobile browser restores the page from hidden/pageshow after the soft keyboard collapses while the focused input remains active."
symptoms:
- "Returning to the dashboard on iOS can leave mobile layout in a keyboard-open state after the keyboard is already down"
- "Viewport height/offset metrics remain stale when an input stays focused across the hidden → visible or pageshow transition"
- "Footer/mobile-nav spacing can stay suppressed until a later resize or blur event corrects the metrics"
root_cause: stale_visualviewport_sample_held_after_restore
resolution_type: code_fix
severity: medium
related_components:
- packages/dashboard/app/App.tsx
- packages/dashboard/app/utils/mobileBarKeyboardFlags.ts
- FN-5155
- FN-6362
tags:
- mobile-keyboard
- visualviewport
- ios
- pageshow
- visibilitychange
- viewport-metrics
---
# Mobile keyboard restore stale viewport reset
## Problem
`useMobileKeyboard` protects normal in-session keyboard handling from impossible iOS samples: when an input is focused, a transient sample that reports a restored full viewport but still carries stale open-keyboard metrics can be held so the dashboard does not flicker. That FN-5155 guard is useful while the page is active, but it also masked a real restore transition.
When the app returned from `hidden`/`pageshow` with the soft keyboard collapsed and the focused input still active, the hook reused the previous open-keyboard metrics. Because focus remained on the input, the impossible-sample hold treated the collapsed restore sample as suspicious and kept `keyboardOpen`, `viewportHeight`, and `offsetTop` stale until another resize or blur arrived.
## Solution
Handle page restore as a distinct sampling path rather than weakening the normal in-session guard.
- On `visibilitychange` back to `visible` and on `pageshow`, take an immediate restore sample.
- If the restore sample is a collapsed/full-height viewport, reset the baseline viewport height and bypass the impossible-sample hold for that one sample.
- Keep FN-5155's impossible-sample hold in place for regular resize/focus/tail updates.
- Continue scheduling delayed tail updates after restore so later iOS viewport corrections still land.
This lets a collapsed restore clear `keyboardOpen`, `viewportHeight`, and `offsetTop` even when `document.activeElement` is still an input, while a genuinely open restored keyboard remains open.
## Regression coverage
Cover restore as a surface invariant, not only the single iOS reproduction:
- `visibilitychange` from hidden to visible with retained focus and a collapsed viewport resets stale open-keyboard metrics.
- `pageshow` with stale positive `visualViewport.offsetTop` drift clears the keyboard state when the viewport is full height.
- A genuinely shrunken restored viewport remains keyboard-open.
- Android-style shrink metrics reset without carrying iOS offset drift.
- Existing FN-5155 in-session impossible-sample coverage remains green, proving the normal guard was not removed.
The hook-level test seam is preferable here because callers already consume the hook-provided `keyboardOpen` and viewport values; no consumer-specific behavior needed to change.

View File

@@ -592,6 +592,166 @@ describe("useMobileKeyboard", () => {
}
});
it("FN-6362: resets stale iOS keyboard metrics on visibility restore when the keyboard collapsed but focus remains", async () => {
const { listeners, mockVV } = setupMobileVisualViewport({
innerHeight: 844,
vvHeight: 844,
});
const input = document.createElement("textarea");
document.body.appendChild(input);
const { result } = renderHook(() => useMobileKeyboard());
input.focus();
Object.defineProperty(mockVV, "height", { value: 520, writable: true, configurable: true });
Object.defineProperty(mockVV, "offsetTop", { value: 180, writable: true, configurable: true });
act(() => {
for (const cb of listeners.resize) cb();
});
await waitFor(() => {
expect(result.current.keyboardOpen).toBe(true);
expect(result.current.viewportOffsetTop).toBe(180);
});
// iOS can restore with the visual viewport back at full height while
// window.innerHeight still reflects the pre-background keyboard shrink.
// The retained focused input plus impossible sample used to hold the stale
// keyboard-open metrics forever because no blur/resize followed.
Object.defineProperty(window, "innerHeight", { value: 520, writable: true, configurable: true });
Object.defineProperty(mockVV, "height", { value: 844, writable: true, configurable: true });
Object.defineProperty(mockVV, "offsetTop", { value: 0, writable: true, configurable: true });
Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true });
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
await waitFor(() => {
expect(result.current.keyboardOpen).toBe(false);
expect(result.current.viewportOffsetTop).toBe(0);
expect(result.current.viewportHeight).toBeNull();
});
input.remove();
});
it("FN-6362: resets stale iOS keyboard metrics on pageshow when stale offset drift remains", async () => {
const { listeners, mockVV } = setupMobileVisualViewport({
innerHeight: 844,
vvHeight: 844,
});
const input = document.createElement("textarea");
document.body.appendChild(input);
const { result } = renderHook(() => useMobileKeyboard());
input.focus();
Object.defineProperty(mockVV, "height", { value: 520, writable: true, configurable: true });
Object.defineProperty(mockVV, "offsetTop", { value: 160, writable: true, configurable: true });
act(() => {
for (const cb of listeners.resize) cb();
});
await waitFor(() => {
expect(result.current.keyboardOpen).toBe(true);
expect(result.current.viewportOffsetTop).toBe(160);
});
Object.defineProperty(window, "innerHeight", { value: 520, writable: true, configurable: true });
Object.defineProperty(mockVV, "height", { value: 844, writable: true, configurable: true });
Object.defineProperty(mockVV, "offsetTop", { value: 120, writable: true, configurable: true });
const pageshow = new Event("pageshow") as PageTransitionEvent;
Object.defineProperty(pageshow, "persisted", { value: false });
act(() => {
window.dispatchEvent(pageshow);
});
await waitFor(() => {
expect(result.current.keyboardOpen).toBe(false);
expect(result.current.viewportOffsetTop).toBe(0);
expect(result.current.viewportHeight).toBeNull();
});
input.remove();
});
it("FN-6362: keeps a genuinely-open restored viewport open", async () => {
const { mockVV } = setupMobileVisualViewport({
innerHeight: 844,
vvHeight: 844,
});
const input = document.createElement("textarea");
document.body.appendChild(input);
const { result } = renderHook(() => useMobileKeyboard());
input.focus();
Object.defineProperty(window, "innerHeight", { value: 520, writable: true, configurable: true });
Object.defineProperty(mockVV, "height", { value: 520, writable: true, configurable: true });
Object.defineProperty(mockVV, "offsetTop", { value: 0, writable: true, configurable: true });
act(() => {
window.dispatchEvent(new Event("pageshow"));
});
await waitFor(() => {
expect(result.current.keyboardOpen).toBe(true);
expect(result.current.viewportOffsetTop).toBe(0);
expect(result.current.viewportHeight).toBe(520);
});
input.remove();
});
it("FN-6362: resets Android-style shrink metrics on restore without introducing offset drift", async () => {
const { listeners, mockVV } = setupMobileVisualViewport({
innerHeight: 800,
vvHeight: 800,
});
const input = document.createElement("textarea");
document.body.appendChild(input);
const { result } = renderHook(() => useMobileKeyboard());
input.focus();
Object.defineProperty(mockVV, "height", { value: 500, writable: true, configurable: true });
Object.defineProperty(mockVV, "offsetTop", { value: 0, writable: true, configurable: true });
act(() => {
for (const cb of listeners.resize) cb();
});
await waitFor(() => {
expect(result.current.keyboardOpen).toBe(true);
expect(result.current.viewportOffsetTop).toBe(0);
expect(result.current.viewportHeight).toBe(500);
});
Object.defineProperty(mockVV, "height", { value: 800, writable: true, configurable: true });
Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true });
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
await waitFor(() => {
expect(result.current.keyboardOpen).toBe(false);
expect(result.current.viewportOffsetTop).toBe(0);
expect(result.current.viewportHeight).toBeNull();
});
input.remove();
});
// FN-3290 regression: focusout must reset keyboard state when input blurs
describe("FN-3290: focusout resets keyboard state", () => {
it("resets keyboardOpen to false on focusout when viewport returns to baseline", async () => {

View File

@@ -34,6 +34,10 @@ function updateBaselineViewportHeight(nextHeight: number): void {
}
}
function resetBaselineViewportHeight(): void {
_baselineViewportHeight = null;
}
function isKeyboardFocusableElement(el: Element | null): boolean {
if (!el) return false;
if (el instanceof HTMLTextAreaElement) return true;
@@ -66,7 +70,18 @@ function hasImpossibleViewportSample(): boolean {
return window.visualViewport.offsetTop + window.visualViewport.height > window.innerHeight + IMPOSSIBLE_VIEWPORT_EPSILON_PX;
}
function getKeyboardMetrics(previousMetrics: KeyboardMetrics = CLOSED_KEYBOARD_METRICS): KeyboardMetrics {
function isCollapsedRestoreViewportSample(baselineHeight: number): boolean {
if (typeof window === "undefined" || !window.visualViewport) {
return false;
}
return window.visualViewport.height >= baselineHeight - IOS_VIEWPORT_SHRINK_MIN_PX;
}
function getKeyboardMetrics(
previousMetrics: KeyboardMetrics = CLOSED_KEYBOARD_METRICS,
{ bypassImpossibleSampleHold = false }: { bypassImpossibleSampleHold?: boolean } = {},
): KeyboardMetrics {
if (typeof window === "undefined" || !window.visualViewport) {
return CLOSED_KEYBOARD_METRICS;
}
@@ -92,7 +107,7 @@ function getKeyboardMetrics(previousMetrics: KeyboardMetrics = CLOSED_KEYBOARD_M
// FN-5155: iOS focus/restore can briefly report offsetTop from the keyboard
// transition while height is still near the pre-keyboard baseline. Reject
// that impossible snapshot and keep the last stable metrics until settle.
if (focused && hasImpossibleViewportSample()) {
if (focused && hasImpossibleViewportSample() && !bypassImpossibleSampleHold) {
return previousMetrics;
}
@@ -139,7 +154,7 @@ function getKeyboardMetrics(previousMetrics: KeyboardMetrics = CLOSED_KEYBOARD_M
/** Reset cached viewport baseline. Exported for tests only. */
export function _resetInitialViewportHeight(): void {
_baselineViewportHeight = null;
resetBaselineViewportHeight();
}
interface UseMobileKeyboardOptions {
@@ -267,19 +282,7 @@ export function useMobileKeyboard(
stableFrames = 0;
rafId = window.requestAnimationFrame(pollFrame);
};
const updateWithTail = () => {
cancelHeadUpdate();
if (isKeyboardFocusableElement(document.activeElement) && hasImpossibleViewportSample()) {
// FN-5155: focusin/page-restore can arrive before visualViewport height
// catches up to the keyboard transition. Defer the head commit one frame
// so the tail/poll can converge instead of publishing the stale sample.
headRafId = window.requestAnimationFrame(() => {
headRafId = null;
update();
});
} else {
update();
}
const scheduleTailUpdates = () => {
scheduleUpdate(50);
scheduleUpdate(200);
scheduleUpdate(500);
@@ -288,24 +291,58 @@ export function useMobileKeyboard(
startStabilityPoll();
};
const updateWithTail = () => {
cancelHeadUpdate();
if (isKeyboardFocusableElement(document.activeElement) && hasImpossibleViewportSample()) {
// FN-5155: focusin can arrive before visualViewport height catches up
// to the keyboard transition. Defer the head commit one frame so the
// tail/poll can converge instead of publishing the stale sample.
headRafId = window.requestAnimationFrame(() => {
headRafId = null;
update();
});
} else {
update();
}
scheduleTailUpdates();
};
const resetOnRestore = () => {
cancelHeadUpdate();
const baselineHeight = getBaselineViewportHeight();
const collapsedRestoreSample = isCollapsedRestoreViewportSample(baselineHeight);
if (collapsedRestoreSample) {
resetBaselineViewportHeight();
}
commitMetrics(getKeyboardMetrics(stableMetricsRef.current, {
bypassImpossibleSampleHold: collapsedRestoreSample,
}));
scheduleTailUpdates();
};
const handleVisibilityChange = () => {
if (document.visibilityState !== "visible") return;
resetOnRestore();
};
updateWithTail();
vv.addEventListener("resize", update);
vv.addEventListener("scroll", updateScrollOnly);
document.addEventListener("focusin", updateWithTail);
document.addEventListener("focusout", update);
// When the user navigates back to this view, force a fresh snapshot
// — without it the hook initializes with stale metrics (keyboard up
// from before, but our state thinks it's closed).
document.addEventListener("visibilitychange", updateWithTail);
window.addEventListener("pageshow", updateWithTail);
// When the user navigates back to this view, force a fresh snapshot that
// can bypass the stale impossible-sample hold if the viewport has already
// returned to its closed baseline while the input retained focus.
document.addEventListener("visibilitychange", handleVisibilityChange);
window.addEventListener("pageshow", resetOnRestore);
return () => {
vv.removeEventListener("resize", update);
vv.removeEventListener("scroll", updateScrollOnly);
document.removeEventListener("focusin", updateWithTail);
document.removeEventListener("focusout", update);
document.removeEventListener("visibilitychange", updateWithTail);
window.removeEventListener("pageshow", updateWithTail);
document.removeEventListener("visibilitychange", handleVisibilityChange);
window.removeEventListener("pageshow", resetOnRestore);
for (const timeoutId of timeoutIds) {
clearTimeout(timeoutId);
}