From fb102a86d5c2c230373b6edb75bf2e43edb6c507 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 13 Jun 2026 10:14:11 -0700 Subject: [PATCH] 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 --- .../mobile-keyboard-restore-stale-viewport.md | 59 +++++++ .../hooks/__tests__/useMobileKeyboard.test.ts | 160 ++++++++++++++++++ .../dashboard/app/hooks/useMobileKeyboard.ts | 83 ++++++--- 3 files changed, 279 insertions(+), 23 deletions(-) create mode 100644 docs/solutions/ui-bugs/mobile-keyboard-restore-stale-viewport.md diff --git a/docs/solutions/ui-bugs/mobile-keyboard-restore-stale-viewport.md b/docs/solutions/ui-bugs/mobile-keyboard-restore-stale-viewport.md new file mode 100644 index 0000000000..2eb5e8bc30 --- /dev/null +++ b/docs/solutions/ui-bugs/mobile-keyboard-restore-stale-viewport.md @@ -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. diff --git a/packages/dashboard/app/hooks/__tests__/useMobileKeyboard.test.ts b/packages/dashboard/app/hooks/__tests__/useMobileKeyboard.test.ts index 86dc623264..eaaf1e7385 100644 --- a/packages/dashboard/app/hooks/__tests__/useMobileKeyboard.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useMobileKeyboard.test.ts @@ -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 () => { diff --git a/packages/dashboard/app/hooks/useMobileKeyboard.ts b/packages/dashboard/app/hooks/useMobileKeyboard.ts index fca5d3497c..fe37576aff 100644 --- a/packages/dashboard/app/hooks/useMobileKeyboard.ts +++ b/packages/dashboard/app/hooks/useMobileKeyboard.ts @@ -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); }