From fa8cf53c56b65a2ca23f5e8db06b9c3af26a8842 Mon Sep 17 00:00:00 2001 From: Haoqing Wang <78337154+hqhq1025@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:40:25 +0800 Subject: [PATCH] fix(web): prevent iOS PWA keyboard from pushing header behind status bar (#454) (#457) On iOS PWA (black-translucent + viewport-fit=cover), opening the virtual keyboard causes iOS to scroll the page upward, pushing the session header behind the system status bar. Fix by resetting window.scrollTo(0, 0) when the keyboard is detected open, and listening to visualViewport scroll events in addition to resize to catch any deferred scrolling. via [HAPI](https://hapi.run) Co-authored-by: HAPI --- web/src/hooks/useViewportHeight.test.ts | 43 +++++++++++++++++++++++++ web/src/hooks/useViewportHeight.ts | 11 +++++++ 2 files changed, 54 insertions(+) diff --git a/web/src/hooks/useViewportHeight.test.ts b/web/src/hooks/useViewportHeight.test.ts index 8262af94..5a2d6664 100644 --- a/web/src/hooks/useViewportHeight.test.ts +++ b/web/src/hooks/useViewportHeight.test.ts @@ -60,4 +60,47 @@ describe('useViewportHeight update logic', () => { expect(root.style.getPropertyValue('--app-viewport-height')).toBe('') }) + + it('resets page scroll when keyboard is open', () => { + const scrollToSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}) + + // Simulate: keyboard open AND page has been scrolled by iOS + Object.defineProperty(window, 'scrollY', { value: 120, configurable: true }) + + const viewportHeight = 400 + const windowHeight = 800 + const diff = windowHeight - viewportHeight + if (diff > 1) { + root.style.setProperty('--app-viewport-height', `${viewportHeight}px`) + if (window.scrollY > 0) { + window.scrollTo(0, 0) + } + } + + expect(scrollToSpy).toHaveBeenCalledWith(0, 0) + + // Cleanup + Object.defineProperty(window, 'scrollY', { value: 0, configurable: true }) + scrollToSpy.mockRestore() + }) + + it('does not reset scroll when page is not scrolled', () => { + const scrollToSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}) + + Object.defineProperty(window, 'scrollY', { value: 0, configurable: true }) + + const viewportHeight = 400 + const windowHeight = 800 + const diff = windowHeight - viewportHeight + if (diff > 1) { + root.style.setProperty('--app-viewport-height', `${viewportHeight}px`) + if (window.scrollY > 0) { + window.scrollTo(0, 0) + } + } + + expect(scrollToSpy).not.toHaveBeenCalled() + + scrollToSpy.mockRestore() + }) }) diff --git a/web/src/hooks/useViewportHeight.ts b/web/src/hooks/useViewportHeight.ts index b8c500c8..fbf206f3 100644 --- a/web/src/hooks/useViewportHeight.ts +++ b/web/src/hooks/useViewportHeight.ts @@ -32,15 +32,26 @@ export function useViewportHeight(): void { const diff = window.innerHeight - viewport.height if (diff > 1) { root.style.setProperty('--app-viewport-height', `${viewport.height}px`) + // On iOS PWA (black-translucent status bar + viewport-fit=cover), + // the browser scrolls the page upward when the keyboard opens to + // keep the focused input visible. This pushes the header behind + // the iOS status bar. Reset the page scroll so the app stays + // pinned to the top — the inner flex layout already handles + // keeping the composer visible. + if (window.scrollY > 0) { + window.scrollTo(0, 0) + } } else { root.style.removeProperty('--app-viewport-height') } } viewport.addEventListener('resize', update) + viewport.addEventListener('scroll', update) return () => { viewport.removeEventListener('resize', update) + viewport.removeEventListener('scroll', update) root.style.removeProperty('--app-viewport-height') } }, [])