From 38007db549a55c5a23d658da0491d347d8417984 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 13 Jun 2026 10:29:24 -0700 Subject: [PATCH] FN-6364: reset iOS mobile viewport on restore Recover stale iOS document scroll after returning to the Fusion dashboard. - Add an iOS-only restore hook that clears orphaned body offsets and scrolls the document back to the origin when unlocked. - Wire the restore reset from the dashboard app shell for mobile layouts. - Cover visible/page-show restores, platform no-ops, active-lock guards, and orphaned style cleanup. - Document the mobile restore drift solution for future regressions. Files changed: .../mobile-ios-restore-document-scroll-drift.md | 59 ++++++++++ packages/dashboard/app/App.tsx | 4 +- .../hooks/__tests__/useMobileScrollLock.test.ts | 121 ++++++++++++++++++++- .../dashboard/app/hooks/useMobileScrollLock.ts | 61 +++++++++++ 4 files changed, 243 insertions(+), 2 deletions(-) Fusion-Task-Id: FN-6364 Fusion-Task-Lineage: 9243cab8-5fbb-4332-ac4d-aabefde4161c --- ...obile-ios-restore-document-scroll-drift.md | 59 +++++++++ packages/dashboard/app/App.tsx | 4 +- .../__tests__/useMobileScrollLock.test.ts | 121 +++++++++++++++++- .../app/hooks/useMobileScrollLock.ts | 61 +++++++++ 4 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 docs/solutions/ui-bugs/mobile-ios-restore-document-scroll-drift.md diff --git a/docs/solutions/ui-bugs/mobile-ios-restore-document-scroll-drift.md b/docs/solutions/ui-bugs/mobile-ios-restore-document-scroll-drift.md new file mode 100644 index 0000000000..0b744fdd94 --- /dev/null +++ b/docs/solutions/ui-bugs/mobile-ios-restore-document-scroll-drift.md @@ -0,0 +1,59 @@ +--- +title: "Mobile iOS restore document scroll drift" +date: 2026-06-13 +category: ui-bugs +module: packages/dashboard/app/hooks/useMobileScrollLock +problem_type: ui_bug +component: frontend_mobile_layout +applies_when: "An iOS Safari/PWA dashboard tab is restored from background or bfcache after the document has stale scroll or orphaned body offset." +symptoms: + - "Returning to Fusion on iOS can leave the header/board pushed above the top of the screen" + - "A large empty gap appears at the bottom even though the soft keyboard is down" + - "The dashboard resting layout should have document scroll at the origin because body overflow is hidden" +root_cause: ios_restore_left_stale_document_scroll_or_body_offset +resolution_type: code_fix +severity: medium +related_components: + - packages/dashboard/app/App.tsx + - packages/dashboard/app/hooks/useMobileScrollLock.ts + - packages/dashboard/app/hooks/useMobileKeyboard.ts + - FN-6362 + - FN-6364 +tags: + - ios-safari + - mobile-keyboard + - document-scroll + - visualviewport + - bfcache +--- + +# Mobile iOS restore document scroll drift + +## Problem + +On iOS Safari/PWA, switching away from Fusion and returning can leave the layout viewport visually misaligned with the dashboard. The document may retain `window.scrollY > 0`, or a stale inline body offset from an earlier lock, even though Fusion's base shell uses `body { overflow: hidden }` and the resting document scroll position should be `(0, 0)`. + +The visible symptom is the board/header appearing shifted upward with an empty gap at the bottom after foregrounding the app, including cases where no input is currently focused. + +## Solution + +Keep keyboard metrics recovery and document-scroll recovery as separate concerns: + +- FN-6362 resets `useMobileKeyboard` metrics on `visibilitychange`/`pageshow` so `--vv-offset-top` consumers stop seeing a stale keyboard-open state. +- FN-6364 adds `useMobileViewportRestoreReset` in `useMobileScrollLock.ts` and wires it once from `App.tsx` for mobile layouts. + +The restore hook only runs on iOS mobile devices. On `document.visibilitychange` it acts only when `document.visibilityState === "visible"`, and on `window.pageshow` it handles normal and bfcache restores. If no fullscreen scroll lock or keyboard viewport lock is active, it clears orphaned body fixed-position offset styles and calls `window.scrollTo(0, 0)` when stale document scroll is present. + +Do not run this reset on Android or desktop, and do not run it while `useMobileScrollLock` or `useMobileKeyboardViewportLock` is active; live locks own their own restore path. + +## Regression coverage + +Cover the invariant at the `useMobileScrollLock` hook seam: + +- iOS mobile + `visibilitychange` to visible + `scrollY > 0` calls `scrollTo(0, 0)`. +- iOS mobile + `pageshow` with `persisted: false` calls `scrollTo(0, 0)`. +- Android and desktop restore events are no-ops. +- `visibilitychange` to hidden is a no-op. +- Active fullscreen scroll locks and keyboard viewport locks prevent the restore hook from fighting the live lock. +- `scrollY === 0` is idempotent. +- Orphaned body `position: fixed` / `top` offset is cleared only when no lock is active. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 4d2ac4256d..358e603710 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -63,7 +63,7 @@ import { useDeepLink } from "./hooks/useDeepLink"; import { useFavorites } from "./hooks/useFavorites"; import { useAuthOnboarding } from "./hooks/useAuthOnboarding"; import { useMobileKeyboard } from "./hooks/useMobileKeyboard"; -import { isIOS, useMobileKeyboardViewportLock } from "./hooks/useMobileScrollLock"; +import { isIOS, useMobileKeyboardViewportLock, useMobileViewportRestoreReset } from "./hooks/useMobileScrollLock"; import { computeMobileBarKeyboardFlags } from "./utils/mobileBarKeyboardFlags"; import { useSetupReadiness } from "./hooks/useSetupReadiness"; import { useUpdateCheck } from "./hooks/useUpdateCheck"; @@ -545,6 +545,8 @@ function AppInner() { // into place when the keyboard dismisses. Modals manage their own lock // via useMobileScrollLock — the reference-counted hook handles overlap. useMobileKeyboardViewportLock(mobileKeyboardOpen); + // Complements FN-6362's keyboard metrics reset by recovering stale document scroll on foreground. + useMobileViewportRestoreReset(isMobile); // App-level mailbox/chat unread state (used for header/mobile nav badges) const [mailboxUnreadCount, setMailboxUnreadCount] = useState(0); diff --git a/packages/dashboard/app/hooks/__tests__/useMobileScrollLock.test.ts b/packages/dashboard/app/hooks/__tests__/useMobileScrollLock.test.ts index 7e915bbc0a..209c1458d9 100644 --- a/packages/dashboard/app/hooks/__tests__/useMobileScrollLock.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useMobileScrollLock.test.ts @@ -1,6 +1,11 @@ import { renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { _resetLockState, useMobileScrollLock } from "../useMobileScrollLock"; +import { + _resetLockState, + useMobileKeyboardViewportLock, + useMobileScrollLock, + useMobileViewportRestoreReset, +} from "../useMobileScrollLock"; describe("useMobileScrollLock", () => { let savedInnerWidth: number; @@ -20,6 +25,7 @@ describe("useMobileScrollLock", () => { scrollSpy = vi.fn(); window.scrollTo = scrollSpy as unknown as typeof window.scrollTo; Object.defineProperty(window, "scrollY", { value: 0, writable: true, configurable: true }); + Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true }); }); afterEach(() => { @@ -59,6 +65,119 @@ describe("useMobileScrollLock", () => { Object.defineProperty(window, "innerWidth", { value: 1280, writable: true, configurable: true }); } + function setVisibilityState(value: DocumentVisibilityState) { + Object.defineProperty(document, "visibilityState", { value, configurable: true }); + } + + it("snaps stale iOS document scroll to top on visibilitychange restore", () => { + makeMobile(); + Object.defineProperty(window, "scrollY", { value: 120, writable: true, configurable: true }); + renderHook(() => useMobileViewportRestoreReset(true)); + + setVisibilityState("visible"); + document.dispatchEvent(new Event("visibilitychange")); + + expect(scrollSpy).toHaveBeenCalledWith(0, 0); + }); + + it("snaps stale iOS document scroll to top on pageshow restore", () => { + makeMobile(); + Object.defineProperty(window, "scrollY", { value: 120, writable: true, configurable: true }); + renderHook(() => useMobileViewportRestoreReset(true)); + + window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: false })); + + expect(scrollSpy).toHaveBeenCalledWith(0, 0); + }); + + it("does not reset document scroll on Android restore", () => { + makeAndroid(); + Object.defineProperty(window, "scrollY", { value: 120, writable: true, configurable: true }); + renderHook(() => useMobileViewportRestoreReset(true)); + + document.dispatchEvent(new Event("visibilitychange")); + window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: false })); + + expect(scrollSpy).not.toHaveBeenCalled(); + }); + + it("does not reset document scroll on desktop restore", () => { + makeDesktop(); + Object.defineProperty(window, "scrollY", { value: 120, writable: true, configurable: true }); + renderHook(() => useMobileViewportRestoreReset(true)); + + document.dispatchEvent(new Event("visibilitychange")); + window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: false })); + + expect(scrollSpy).not.toHaveBeenCalled(); + }); + + it("does not reset document scroll on visibilitychange hidden", () => { + makeMobile(); + Object.defineProperty(window, "scrollY", { value: 120, writable: true, configurable: true }); + renderHook(() => useMobileViewportRestoreReset(true)); + + setVisibilityState("hidden"); + document.dispatchEvent(new Event("visibilitychange")); + + expect(scrollSpy).not.toHaveBeenCalled(); + }); + + it("does not fight an active fullscreen mobile scroll lock on restore", () => { + makeMobile(); + Object.defineProperty(window, "scrollY", { value: 120, writable: true, configurable: true }); + renderHook(() => useMobileScrollLock(true)); + renderHook(() => useMobileViewportRestoreReset(true)); + + document.dispatchEvent(new Event("visibilitychange")); + window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: false })); + + expect(scrollSpy).not.toHaveBeenCalled(); + }); + + it("does not fight an active keyboard viewport lock on restore", () => { + makeMobile(); + renderHook(() => useMobileKeyboardViewportLock(true)); + scrollSpy.mockClear(); + Object.defineProperty(window, "scrollY", { value: 120, writable: true, configurable: true }); + renderHook(() => useMobileViewportRestoreReset(true)); + + document.dispatchEvent(new Event("visibilitychange")); + window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: false })); + + expect(scrollSpy).not.toHaveBeenCalled(); + }); + + it("is idempotent when already aligned on restore", () => { + makeMobile(); + renderHook(() => useMobileViewportRestoreReset(true)); + + document.dispatchEvent(new Event("visibilitychange")); + + expect(scrollSpy).not.toHaveBeenCalled(); + expect(document.body.style.position).toBe(""); + expect(document.body.style.top).toBe(""); + }); + + it("clears orphaned body offset styles without a live lock", () => { + makeMobile(); + document.body.style.position = "fixed"; + document.body.style.top = "-120px"; + document.body.style.left = "0"; + document.body.style.right = "0"; + document.body.style.width = "100%"; + renderHook(() => useMobileViewportRestoreReset(true)); + + document.dispatchEvent(new Event("visibilitychange")); + + expect(document.body.style.position).toBe(""); + expect(document.body.style.top).toBe(""); + expect(document.body.style.left).toBe(""); + expect(document.body.style.right).toBe(""); + expect(document.body.style.width).toBe(""); + expect(scrollSpy).not.toHaveBeenCalled(); + }); + it("pins body with position:fixed and overflow:hidden on mobile when enabled", () => { makeMobile(); Object.defineProperty(window, "scrollY", { value: 120, writable: true, configurable: true }); diff --git a/packages/dashboard/app/hooks/useMobileScrollLock.ts b/packages/dashboard/app/hooks/useMobileScrollLock.ts index 86891ab5fd..bbe961134e 100644 --- a/packages/dashboard/app/hooks/useMobileScrollLock.ts +++ b/packages/dashboard/app/hooks/useMobileScrollLock.ts @@ -120,6 +120,38 @@ function releaseLock(): void { void scrollY; } +export function isAnyMobileScrollLockActive(): boolean { + return lockCount > 0 || kbLockCount > 0; +} + +function clearOrphanedBodyOffset(): void { + if (savedStyles !== null || kbSavedStyles !== null) return; + const body = document.body; + if (body.style.position === "fixed") { + body.style.position = ""; + } + if (body.style.top) { + body.style.top = ""; + } + if (body.style.left === "0px") { + body.style.left = ""; + } + if (body.style.right === "0px") { + body.style.right = ""; + } + if (body.style.width === "100%") { + body.style.width = ""; + } +} + +function resetStaleDocumentScrollOnRestore(): void { + if (isAnyMobileScrollLockActive()) return; + clearOrphanedBodyOffset(); + if (window.scrollY > 0) { + window.scrollTo(0, 0); + } +} + /** Test-only: reset the module-level lock state. */ export function _resetLockState(): void { lockCount = 0; @@ -194,6 +226,35 @@ export function useMobileKeyboardViewportLock(enabled: boolean): void { }, [enabled]); } +/** + * Snap stale iOS document scroll/body offset back to the dashboard's resting + * position when the page is restored from background or bfcache. Active locks + * own their own restore path, so this only runs when the page is otherwise + * unlocked. + */ +export function useMobileViewportRestoreReset(enabled: boolean): void { + useEffect(() => { + if (!enabled || !isMobileDevice() || !isIOS()) return; + + const handleVisibilityChange = () => { + if (document.visibilityState !== "visible") return; + resetStaleDocumentScrollOnRestore(); + }; + + const handlePageShow = () => { + resetStaleDocumentScrollOnRestore(); + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + window.addEventListener("pageshow", handlePageShow); + + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + window.removeEventListener("pageshow", handlePageShow); + }; + }, [enabled]); +} + /** * Lock body scroll and pin position while a fullscreen mobile overlay is * open. Recovers iOS visualViewport drift on cleanup. No-op on desktop.