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