From 722457312cc1654dd512d50cffd6fb733d3d479f Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Thu, 2 Jul 2026 21:48:31 +0800 Subject: [PATCH] fix: re-anchor stray iOS layout-viewport scroll that broke nav taps --- frontend/app.js | 43 +++++++++++++++++- tests/viewport-anchor.smoke.spec.js | 67 +++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 tests/viewport-anchor.smoke.spec.js diff --git a/frontend/app.js b/frontend/app.js index 47b4601..e0ada3b 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -2303,6 +2303,40 @@ function setupPortraitPwaWatcher() { }); } +// ============================================================================ +// Layout-viewport anchor guard (iOS Safari / standalone PWA) +// +// The app is a fixed-viewport layout: body is overflow:hidden and only inner +// panes scroll, so the document itself must always sit at scroll position 0. +// iOS WebKit can still scroll the *layout viewport* behind our back — exiting +// native video fullscreen (webkitEnterFullscreen is the only fullscreen path +// on iPhone, and iOS enters it by itself when the phone rotates while a video +// plays), the on-screen keyboard revealing a focused input, or any +// scrollIntoView() walking up into . Once that happens, fixed elements +// (bottom nav, mini-bar) are still *drawn* in place but their hit-testing +// regions are offset by the stray scroll amount, so taps on the nav buttons +// silently do nothing — and because the body isn't user-scrollable there is +// no gesture that can undo it. That is the "nav buttons stop working after +// playing a video" iPhone bug. Snap the document back to 0 whenever it ends +// up scrolled; skipped while an input is focused so we never fight the +// keyboard auto-scroll, then re-anchored once focus leaves the field. +function setupViewportAnchorGuard() { + const editing = () => { + const el = document.activeElement; + return !!el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable); + }; + const reanchor = () => { + if (editing()) return; + const doc = document.scrollingElement || document.documentElement; + if (window.scrollY || doc.scrollTop) window.scrollTo(0, 0); + }; + window.addEventListener('scroll', reanchor); + document.addEventListener('focusout', () => setTimeout(reanchor, 50)); + // Exiting native video fullscreen is the most reliable reproducer of the + // stray-scroll state; the scroll event alone doesn't always fire for it. + els.video.addEventListener('webkitendfullscreen', () => setTimeout(reanchor, 50)); +} + // ============================================================================ // Events // ============================================================================ @@ -2649,8 +2683,12 @@ document.querySelectorAll('.chip').forEach((c) => { $('miniPlayBtn').addEventListener('click', (e) => { e.stopPropagation(); Player.toggle(); }); $('miniBar').addEventListener('click', () => { hideMiniBar(); - // Scroll the player into view if needed - els.playerPane.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + // Scroll the player into view if needed. In portrait PWA, scroll only the + // list-pane (the designated scroll container) — scrollIntoView() also + // scrolls overflow:hidden ancestors up to on iOS, leaving the + // document offset and fixed-element hit testing broken. + if (isPortraitPWA()) scrollPlayerIntoViewPortrait(); + else els.playerPane.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }); $('miniCloseBtn').addEventListener('click', (e) => { e.stopPropagation(); hideMiniBar(); }); @@ -3104,6 +3142,7 @@ async function boot() { wireUI(); wireShortcutHelp(); setupPortraitPwaWatcher(); + setupViewportAnchorGuard(); try { const loaded = await API.loadData(); if (loaded && typeof loaded === 'object') { diff --git a/tests/viewport-anchor.smoke.spec.js b/tests/viewport-anchor.smoke.spec.js new file mode 100644 index 0000000..aa45c64 --- /dev/null +++ b/tests/viewport-anchor.smoke.spec.js @@ -0,0 +1,67 @@ +/** + * Smoke test for the layout-viewport anchor guard. + * + * Bug: on an iPhone running the installed PWA, playing a video sometimes left + * the bottom-nav buttons unresponsive. iOS WebKit can scroll the document's + * layout viewport behind the app's back (exiting native video fullscreen, + * keyboard dismissal, scrollIntoView walking up into ) even though the + * body is overflow:hidden. Fixed elements are then still drawn in place but + * their hit-testing regions are offset by the stray scroll amount, so taps on + * the nav do nothing — and no user gesture can scroll the document back. + * + * The guard (setupViewportAnchorGuard in app.js) snaps the document back to 0 + * whenever it ends up scrolled, except while an input is focused (so it never + * fights the on-screen keyboard); it re-anchors on blur instead. + * + * The static test page doesn't overflow, so each test injects a tall spacer + * and relaxes the overflow clamp — simulating the scrollable-document state + * iOS leaves behind. + */ +const { test, expect } = require('@playwright/test'); + +test.describe('Viewport anchor guard — stray document scroll', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.waitForSelector('.app', { state: 'attached' }); + await page.evaluate(() => { + const spacer = document.createElement('div'); + spacer.style.height = '3000px'; + document.body.appendChild(spacer); + document.documentElement.style.overflow = 'visible'; + document.body.style.overflow = 'visible'; + }); + }); + + test('snaps the document back to 0 after a stray window scroll', async ({ page }) => { + await page.evaluate(() => { + // boot() focuses the search input; release it so the guard is active. + if (document.activeElement) document.activeElement.blur(); + window.scrollTo(0, 400); + }); + await page.waitForFunction(() => window.scrollY === 0); + expect(await page.evaluate(() => window.scrollY)).toBe(0); + }); + + test('leaves the scroll alone while an input is focused (keyboard), re-anchors on blur', async ({ page }) => { + await page.evaluate(() => { + document.getElementById('searchInput').focus(); + window.scrollTo(0, 300); + }); + // Guard must not fight the keyboard-driven scroll while editing. + await page.waitForTimeout(200); + expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(0); + + await page.evaluate(() => document.getElementById('searchInput').blur()); + await page.waitForFunction(() => window.scrollY === 0); + }); + + test('re-anchors when native video fullscreen exits (webkitendfullscreen)', async ({ page }) => { + await page.evaluate(() => { + if (document.activeElement) document.activeElement.blur(); + window.scrollTo(0, 250); + document.getElementById('video').dispatchEvent(new Event('webkitendfullscreen')); + }); + await page.waitForFunction(() => window.scrollY === 0); + expect(await page.evaluate(() => window.scrollY)).toBe(0); + }); +});