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:
@@ -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.
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user