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
This commit is contained in:
gsxdsm
2026-06-13 10:29:24 -07:00
parent 422833045c
commit 38007db549
4 changed files with 243 additions and 2 deletions

View File

@@ -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.

View File

@@ -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);

View File

@@ -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 });

View File

@@ -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.