fix(dashboard): gate mobile scroll-lock to iOS so Android keyboard stays open

The body scroll-lock applied while the keyboard is up in main chat was an
iOS-specific workaround for visualViewport drift. On Android Chrome the same
mutation does the opposite of what we want — applying position:fixed to body
while the soft keyboard is opening causes Chrome to treat it as a focus-
target relayout and dismiss the keyboard instantly, making the main chat
composer unusable on Android.

useMobileScrollLock now early-returns on non-iOS user agents. Android Chrome
doesn't need it: with interactive-widget=resizes-content the layout viewport
shrinks with the keyboard, so there's no drift to compensate for.

Adds an Android-UA test case that asserts the lock is a no-op there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-22 14:01:09 -07:00
parent e138289a80
commit 60a0012697
3 changed files with 57 additions and 1 deletions

View File

@@ -0,0 +1,9 @@
---
"@fusion/dashboard": patch
---
fix(dashboard): stop ChatView's body scroll-lock from instantly dismissing the Android soft keyboard
The body scroll-lock applied while the keyboard is open in main chat was an iOS-specific workaround for visualViewport drift. On Android Chrome it does the opposite of what we want — mutating `body { position: fixed; ... }` while the keyboard is opening causes Chrome to treat it as a focus-target relayout and immediately dismisses the keyboard, making the main chat composer unusable on Android.
`useMobileScrollLock` is now gated to iOS UAs. Android Chrome doesn't need it (with `interactive-widget=resizes-content` the layout viewport shrinks with the keyboard, so no drift compensation is required).

View File

@@ -6,6 +6,7 @@ describe("useMobileScrollLock", () => {
let savedInnerWidth: number;
let savedMaxTouchPoints: number;
let savedOntouchstart: typeof window.ontouchstart;
let savedUserAgent: string;
let scrollSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
@@ -13,6 +14,7 @@ describe("useMobileScrollLock", () => {
savedInnerWidth = window.innerWidth;
savedMaxTouchPoints = navigator.maxTouchPoints;
savedOntouchstart = window.ontouchstart;
savedUserAgent = navigator.userAgent;
document.documentElement.style.cssText = "";
document.body.style.cssText = "";
scrollSpy = vi.fn();
@@ -24,6 +26,7 @@ describe("useMobileScrollLock", () => {
Object.defineProperty(window, "innerWidth", { value: savedInnerWidth, writable: true, configurable: true });
Object.defineProperty(navigator, "maxTouchPoints", { value: savedMaxTouchPoints, configurable: true });
Object.defineProperty(window, "ontouchstart", { value: savedOntouchstart, writable: true, configurable: true });
Object.defineProperty(navigator, "userAgent", { value: savedUserAgent, configurable: true });
document.documentElement.style.cssText = "";
document.body.style.cssText = "";
_resetLockState();
@@ -33,6 +36,21 @@ describe("useMobileScrollLock", () => {
(window as unknown as { ontouchstart: unknown }).ontouchstart = null;
Object.defineProperty(navigator, "maxTouchPoints", { value: 5, configurable: true });
Object.defineProperty(window, "innerWidth", { value: 375, writable: true, configurable: true });
// Hook is iOS-gated; default fixture uses an iPhone UA.
Object.defineProperty(navigator, "userAgent", {
value: "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15",
configurable: true,
});
}
function makeAndroid() {
(window as unknown as { ontouchstart: unknown }).ontouchstart = null;
Object.defineProperty(navigator, "maxTouchPoints", { value: 5, configurable: true });
Object.defineProperty(window, "innerWidth", { value: 388, writable: true, configurable: true });
Object.defineProperty(navigator, "userAgent", {
value: "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36",
configurable: true,
});
}
function makeDesktop() {
@@ -60,6 +78,14 @@ describe("useMobileScrollLock", () => {
expect(scrollSpy).not.toHaveBeenCalled();
});
it("does nothing on Android (avoids dismissing the soft keyboard)", () => {
makeAndroid();
renderHook(() => useMobileScrollLock(true));
expect(document.body.style.position).toBe("");
expect(document.documentElement.style.overflow).toBe("");
expect(scrollSpy).not.toHaveBeenCalled();
});
it("restores prior styles and scroll position on cleanup", () => {
makeMobile();
document.body.style.position = "relative";

View File

@@ -8,6 +8,27 @@ function isMobileDevice(): boolean {
return hasTouchScreen && isNarrow;
}
/**
* The scroll lock is an iOS-specific workaround: iOS Safari shifts the layout
* viewport on input focus (visualViewport.offsetTop > 0) which pushes the
* dashboard off-screen, so we pin body via position:fixed to make it
* unscrollable. Android Chrome does NOT need this — and applying the same
* fix there is actively harmful: mutating body styles while the soft keyboard
* is opening causes Chrome to treat it as a focus-target relayout and
* dismisses the keyboard immediately. So we gate the lock to iOS only.
*
* With `interactive-widget=resizes-content` set on the viewport meta, Android
* Chrome shrinks the layout viewport with the keyboard, so no drift
* compensation is needed there.
*/
function isIOS(): boolean {
if (typeof window === "undefined") return false;
const ua = navigator.userAgent || "";
// iPad on iPadOS 13+ reports as MacIntel + touch — handle that too.
return /iPad|iPhone|iPod/.test(ua)
|| (ua.includes("Macintosh") && navigator.maxTouchPoints > 1);
}
/**
* Reference-counted body scroll lock for fullscreen mobile overlays.
*
@@ -111,7 +132,7 @@ export function _resetLockState(): void {
*/
export function useMobileScrollLock(enabled: boolean): void {
useEffect(() => {
if (!enabled || !isMobileDevice()) return;
if (!enabled || !isMobileDevice() || !isIOS()) return;
applyLock();
return () => {
releaseLock();