diff --git a/browse/src/touch-commands.ts b/browse/src/touch-commands.ts index cb898f41c..77b7635c2 100644 --- a/browse/src/touch-commands.ts +++ b/browse/src/touch-commands.ts @@ -28,7 +28,26 @@ function usage(problem: string, fix: string): Error { return new Error(`Usage: ${problem}\nfix: ${fix}`); } +const FRAME_WAIT_MS = 1000; + +/** + * Chromium maps touch points with the page scale of the last rendered frame; a just-navigated document maps them at the 980px default until it renders. + * The wait runs page JS, so a page that replaces requestAnimationFrame, or navigates mid-wait, only costs FRAME_WAIT_MS, never the gesture queue. + */ +async function renderedFrame(session: TabSession): Promise { + let timer: ReturnType | undefined; + const frame = session.getPage().evaluate(() => document.visibilityState === 'hidden' + ? undefined + : new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + await Promise.race([ + frame.catch(() => {}), + new Promise((resolve) => { timer = setTimeout(resolve, FRAME_WAIT_MS); }), + ]); + clearTimeout(timer); +} + async function dispatch(session: TabSession, type: 'touchStart' | 'touchMove' | 'touchEnd', points: Points): Promise { + if (type === 'touchStart') await renderedFrame(session); const cdp = await tabCdpSession(session.getPage()); session.setFingerCancel(() => { cdp.send('Input.dispatchTouchEvent', { type: 'touchCancel', touchPoints: [] }).catch(() => {}); @@ -153,6 +172,7 @@ async function touch(session: TabSession, args: string[]): Promise { if (held.size === 0) return 'No fingers held; nothing to cancel'; const cdp = await tabCdpSession(session.getPage()); await cdp.send('Input.dispatchTouchEvent', { type: 'touchCancel', touchPoints: [] }); + await renderedFrame(session); held.clear(); return 'Cancelled all held fingers'; } diff --git a/browse/test/touch-commands.test.ts b/browse/test/touch-commands.test.ts index fce783b1d..0e81d72a6 100644 --- a/browse/test/touch-commands.test.ts +++ b/browse/test/touch-commands.test.ts @@ -250,6 +250,15 @@ describe('touch input', () => { expect(await page().evaluate(() => (window as any).__log.at(-1).type)).toBe('touchcancel'); }); + test('a page that replaces requestAnimationFrame still gets its tap, and the next gesture runs', async () => { + await freshTouchTab(); + await page().evaluate(() => { window.requestAnimationFrame = () => 0; }); + const within = (p: Promise) => Promise.race([p.then(() => 'done'), new Promise((resolve) => setTimeout(() => resolve('hung'), 10_000))]); + expect(await within(run('tap', ['#fire']))).toBe('done'); + expect(await within(run('tap', ['#fire']))).toBe('done'); + expect(await data('#fire', 'shots')).toBe('2'); + }); + test('touch cancel with no finger held is a no-op, not a protocol error', async () => { await freshTouchTab(); expect(await run('touch', ['cancel'])).toContain('nothing to cancel'); diff --git a/changelog.d/gsta262-touch-frame.md b/changelog.d/gsta262-touch-frame.md new file mode 100644 index 000000000..1e2fd0ff5 --- /dev/null +++ b/changelog.d/gsta262-touch-frame.md @@ -0,0 +1,3 @@ +### Fixed + +- **A tap or touch right after a page loads now lands where you aimed (GSTA-262).** `$B goto && $B tap 240 440` on a phone-emulated tab could hit a different element. `$B goto` returns once the HTML is parsed, but before the page has drawn a frame with its mobile viewport, and Chromium places touch points using the last drawn frame. So the touch was scaled by 0.401 (393/980, the desktop default width) and landed at (96, 176), on the zoom area instead of the fire button. Every `tap`, `swipe`, `pinch` and `touch down` now waits for the page to draw a frame before the first touch (two animation frames; skipped for a hidden tab, and capped at one second, so a page that replaces `requestAnimationFrame` or navigates mid-wait can't stall the gesture). `$B touch cancel` also waits for a drawn frame before it returns, because Chromium delivers the touchcancel event with the next frame, so a check right after it could still see the old touch. This also fixes the flaky `touch input > held fingers are cancelled on navigation and by touch cancel` test (22 failures on 2026-11-03 and 12 more the next day, all in free shard 4). Under CPU load, a loop of navigate-then-touch missed 45 of 60 times before the fix and 0 of 60 after; the whole test file failed 1 of 20 runs before and 0 of 20 after. A touch-then-cancel loop under the same load saw the stale touch 2 of 60 times before the cancel wait and 0 of 300 after.