From 03e1f4c511485813df5b0925530e0aa6e78e86c5 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Tue, 7 Jul 2026 12:06:48 -0700 Subject: [PATCH 01/18] test(ios): add repro test for setting caret on adjacent thought (#4173) Adds an iOS/wdio regression test that attempts to reproduce #4173, where tapping an adjacent thought within ~1s of a previous tap fails to move the cursor. Introduces a `doubleTap` touch helper that fires two real touch taps with a deterministic sub-second interval, since a hand-driven repro cannot reliably tap fast enough. The bug does not reproduce in the local iOS Simulator via WebDriver touch injection; this test is intended to run on a real BrowserStack device in CI to determine whether the failure reproduces on real hardware. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/e2e/iOS/__tests__/caret.ts | 26 ++++++++++++++ src/e2e/iOS/helpers/doubleTap.ts | 60 ++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 src/e2e/iOS/helpers/doubleTap.ts diff --git a/src/e2e/iOS/__tests__/caret.ts b/src/e2e/iOS/__tests__/caret.ts index 29e773dc8f0..d7b5fd26bbe 100644 --- a/src/e2e/iOS/__tests__/caret.ts +++ b/src/e2e/iOS/__tests__/caret.ts @@ -4,6 +4,7 @@ */ import gestures from '../../../test-helpers/gestures' import clickThought from '../helpers/clickThought' +import doubleTap from '../helpers/doubleTap' import editThought from '../helpers/editThought' import gesture from '../helpers/gesture' import getEditable from '../helpers/getEditable' @@ -251,4 +252,29 @@ describe('Caret', () => { expect(selectionTextContent).toBe('new') expect(childrenTexts).toEqual(['foo', 'bar']) }) + + it('Set caret on adjacent thought within 1 second (#4173)', async () => { + // Reproduces https://github.com/cybersemics/em/issues/4173: tapping an adjacent thought within + // ~1 second of a previous tap fails to move the cursor. doubleTap fires two real touch taps with + // a deterministic sub-second interval, which a hand-driven repro cannot reliably achieve. The + // interval is overridable via TAP_INTERVAL_MS to probe the threshold. + const intervalMs = process.env.TAP_INTERVAL_MS ? parseInt(process.env.TAP_INTERVAL_MS, 10) : 300 + + const importText = ` + - a + - b + - c` + await paste(importText) + await waitForEditable('c') + + const a = await waitForEditable('a') + const b = await waitForEditable('b') + + // Tap a to set the caret, then within intervalMs tap the adjacent thought b. + await doubleTap(a, b, { intervalMs }) + + // Give the app a moment to dispatch setCursor and re-render, then assert the cursor moved to b. + await browser.pause(1000) + expect(await getEditingText()).toBe('b') + }) }) diff --git a/src/e2e/iOS/helpers/doubleTap.ts b/src/e2e/iOS/helpers/doubleTap.ts new file mode 100644 index 00000000000..ac706b6079e --- /dev/null +++ b/src/e2e/iOS/helpers/doubleTap.ts @@ -0,0 +1,60 @@ +import type { Element } from 'webdriverio' +import getElementRectByScreen from './getElementRectByScreen.js' + +interface Options { + // Milliseconds to pause between the release of the first tap and the press of the second tap. + intervalMs?: number + // Milliseconds each tap is held down before release. + holdMs?: number +} + +/** + * Computes the center point of an element in native screen coordinates. + * Touch pointer actions on iOS Safari (XCUITest) are interpreted in screen coordinates, unlike the + * mouse pointer used by the `tap` helper which uses web viewport coordinates. The screen rect + * adds the native Safari content offset so the touch lands on the element. + */ +const centerOf = async (nodeHandle: Element) => { + const exists = await nodeHandle.isExisting() + if (!exists) throw new Error('Element does not exist in the DOM.') + const rect = await getElementRectByScreen(nodeHandle) + if (!rect) throw new Error('Bounding box of element not found.') + return { + x: Math.round(rect.x + rect.width / 2), + y: Math.round(rect.y + rect.height / 2), + } +} + +/** + * Taps two elements in quick succession with a single touch pointer, with a precisely controlled + * interval between the two taps. Both element coordinates are resolved up front so that the gap + * between the taps is exactly intervalMs rather than incidental round-trip latency (which can exceed + * a second and is the reason a hand-driven repro of #4173 is unreliable). Uses a real touch pointer + * (pointerType: 'touch') so WebKit's rapid-tap gesture recognition is exercised the same way a + * physical double tap would exercise it. See https://github.com/cybersemics/em/issues/4173. + */ +const doubleTap = async (first: Element, second: Element, { intervalMs = 300, holdMs = 60 }: Options = {}) => { + const firstPoint = await centerOf(first) + const secondPoint = await centerOf(second) + + await browser.performActions([ + { + type: 'pointer', + id: 'finger1', + parameters: { pointerType: 'touch' }, + actions: [ + { type: 'pointerMove', duration: 0, x: firstPoint.x, y: firstPoint.y, origin: 'viewport' }, + { type: 'pointerDown', button: 0 }, + { type: 'pause', duration: holdMs }, + { type: 'pointerUp', button: 0 }, + { type: 'pause', duration: intervalMs }, + { type: 'pointerMove', duration: 0, x: secondPoint.x, y: secondPoint.y, origin: 'viewport' }, + { type: 'pointerDown', button: 0 }, + { type: 'pause', duration: holdMs }, + { type: 'pointerUp', button: 0 }, + ], + }, + ]) +} + +export default doubleTap From 936629543e32e31d5db625a8b70eab8d999aae82 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Tue, 7 Jul 2026 12:41:04 -0700 Subject: [PATCH 02/18] test(ios): reproduce #4173 with native fat-taps on iOS 18 Adopts the touch techniques proven in #4407 to give the #4173 repro a real chance of failing on hardware: - doubleTap now dispatches from the NATIVE_APP context using a finger-sized contact area (width/height/pressure). A zero-radius webview-context tap does not trigger Safari's touch-adjustment / rapid-tap focus handling and cannot reproduce the bug. Both element centers are resolved in the webview context before switching, so the sub-second interval stays deterministic. - Moves the #4173 test into its own spec (caretAdjacentTap.ts) pinned to a dedicated iOS 18 capability (iPhone 16 Pro Max), mirroring #4407's split. The retargeting behavior fires on iOS 18 but not the suite's default iOS 17, which is why the earlier iOS 17 run gave a false pass. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/e2e/iOS/__tests__/caret.ts | 26 -------- src/e2e/iOS/__tests__/caretAdjacentTap.ts | 44 +++++++++++++ src/e2e/iOS/config/wdio.browserstack.conf.ts | 69 ++++++++++++++------ src/e2e/iOS/helpers/doubleTap.ts | 31 ++++++--- 4 files changed, 114 insertions(+), 56 deletions(-) create mode 100644 src/e2e/iOS/__tests__/caretAdjacentTap.ts diff --git a/src/e2e/iOS/__tests__/caret.ts b/src/e2e/iOS/__tests__/caret.ts index d7b5fd26bbe..29e773dc8f0 100644 --- a/src/e2e/iOS/__tests__/caret.ts +++ b/src/e2e/iOS/__tests__/caret.ts @@ -4,7 +4,6 @@ */ import gestures from '../../../test-helpers/gestures' import clickThought from '../helpers/clickThought' -import doubleTap from '../helpers/doubleTap' import editThought from '../helpers/editThought' import gesture from '../helpers/gesture' import getEditable from '../helpers/getEditable' @@ -252,29 +251,4 @@ describe('Caret', () => { expect(selectionTextContent).toBe('new') expect(childrenTexts).toEqual(['foo', 'bar']) }) - - it('Set caret on adjacent thought within 1 second (#4173)', async () => { - // Reproduces https://github.com/cybersemics/em/issues/4173: tapping an adjacent thought within - // ~1 second of a previous tap fails to move the cursor. doubleTap fires two real touch taps with - // a deterministic sub-second interval, which a hand-driven repro cannot reliably achieve. The - // interval is overridable via TAP_INTERVAL_MS to probe the threshold. - const intervalMs = process.env.TAP_INTERVAL_MS ? parseInt(process.env.TAP_INTERVAL_MS, 10) : 300 - - const importText = ` - - a - - b - - c` - await paste(importText) - await waitForEditable('c') - - const a = await waitForEditable('a') - const b = await waitForEditable('b') - - // Tap a to set the caret, then within intervalMs tap the adjacent thought b. - await doubleTap(a, b, { intervalMs }) - - // Give the app a moment to dispatch setCursor and re-render, then assert the cursor moved to b. - await browser.pause(1000) - expect(await getEditingText()).toBe('b') - }) }) diff --git a/src/e2e/iOS/__tests__/caretAdjacentTap.ts b/src/e2e/iOS/__tests__/caretAdjacentTap.ts new file mode 100644 index 00000000000..aea653d274f --- /dev/null +++ b/src/e2e/iOS/__tests__/caretAdjacentTap.ts @@ -0,0 +1,44 @@ +/** + * IOS Safari caret regression test for #4173. + * + * Isolated into its own spec file so it can be pinned to an iOS version whose Safari touch-adjustment + * / rapid-tap focus handling reproduces the bug (see wdio.browserstack.conf.ts). The rest of the iOS + * suite runs on the default device/version. + */ +import doubleTap from '../helpers/doubleTap' +import getEditingText from '../helpers/getEditingText' +import paste from '../helpers/paste' +import waitForEditable from '../helpers/waitForEditable' + +describe('Caret', () => { + it('Set caret on adjacent thought within 1 second (#4173)', async () => { + // Reproduces https://github.com/cybersemics/em/issues/4173: tapping an adjacent thought within + // ~1 second of a previous tap fails to move the cursor (the second tap's setCursor is not + // dispatched and focus is not triggered on the ContentEditable). A non-adjacent thought works. + // doubleTap fires two real, finger-sized native touch taps with a deterministic sub-second + // interval, which a hand-driven repro cannot reliably achieve. The interval is overridable via + // TAP_INTERVAL_MS to probe the threshold. + const intervalMs = process.env.TAP_INTERVAL_MS ? parseInt(process.env.TAP_INTERVAL_MS, 10) : 300 + + const importText = ` + - a + - b + - c` + await paste(importText) + await waitForEditable('c') + + // Stabilize layout so the touch coordinates resolve against a settled DOM. + await browser.execute(() => window.scrollTo(0, 0)) + await browser.pause(400) + + const a = await waitForEditable('a') + const b = await waitForEditable('b') + + // Tap a to set the caret (and open the keyboard), then within intervalMs tap the adjacent b. + await doubleTap(a, b, { intervalMs }) + + // Give the app a moment to dispatch setCursor and re-render, then assert the cursor moved to b. + await browser.pause(1000) + expect(await getEditingText()).toBe('b') + }) +}) diff --git a/src/e2e/iOS/config/wdio.browserstack.conf.ts b/src/e2e/iOS/config/wdio.browserstack.conf.ts index 3011b87babe..3c152e41409 100644 --- a/src/e2e/iOS/config/wdio.browserstack.conf.ts +++ b/src/e2e/iOS/config/wdio.browserstack.conf.ts @@ -20,6 +20,53 @@ if (!process.env.BROWSERSTACK_ACCESS_KEY) { const user = process.env.BROWSERSTACK_USERNAME const date = new Date().toISOString().slice(0, 10) +// The #4173 regression test only reproduces on a newer iOS whose Safari touch-adjustment / rapid-tap +// focus handling drops the second tap's setCursor. It runs on a dedicated iOS 18 capability while the +// rest of the suite stays on iOS 17. +const caretAdjacentTapSpec = path.resolve(process.cwd(), 'src/e2e/iOS/__tests__/caretAdjacentTap.ts') + +// Shared bstack:options across both capabilities. deviceName/osVersion are set per-capability. +const bstackOptions = { + projectName: process.env.BROWSERSTACK_PROJECT_NAME || 'em', + buildName: process.env.BROWSERSTACK_BUILD_NAME || `Local - ${user} - ${date}`, + sessionName: 'iOS Safari Tests', + // The device reaches the dev server over the public cloudflared HTTPS URL (onPrepare), so + // BrowserStack Local (`local: true`) is not used on this path. These flags collect diagnostic + // data on BrowserStack's web dashboard, which we don't need/use. + debug: false, + networkLogs: false, + consoleLogs: 'errors', + idleTimeout: 60, +} + +// Runs the whole suite except the iOS 18-only #4173 regression test on iOS 17. +// Built as a standalone const (rather than inline in the capabilities array) so the extra +// `exclude`/`specs` keys don't trip the excess-property check when the array is cast below. +const suiteCapability = { + ...baseConfig.baseCapabilities, + exclude: [caretAdjacentTapSpec], + 'appium:deviceName': 'iPhone 15 Plus', + 'appium:platformVersion': '17', + 'bstack:options': { + ...bstackOptions, + deviceName: 'iPhone 15 Plus', + osVersion: '17', + }, +} + +// Runs only the #4173 regression test, which requires iOS 18 to reproduce. +const caretAdjacentTapCapability = { + ...baseConfig.baseCapabilities, + specs: [caretAdjacentTapSpec], + 'appium:deviceName': 'iPhone 16 Pro Max', + 'appium:platformVersion': '18', + 'bstack:options': { + ...bstackOptions, + deviceName: 'iPhone 16 Pro Max', + osVersion: '18', + }, +} + let tunnelProcess: ChildProcess | null = null /** @@ -124,27 +171,7 @@ export const config: WebdriverIO.Config = { key: process.env.BROWSERSTACK_ACCESS_KEY, // Capabilities - capabilities: [ - { - ...baseConfig.baseCapabilities, - 'appium:deviceName': 'iPhone 15 Plus', - 'appium:platformVersion': '17', - 'bstack:options': { - deviceName: 'iPhone 15 Plus', - osVersion: '17', - projectName: process.env.BROWSERSTACK_PROJECT_NAME || 'em', - buildName: process.env.BROWSERSTACK_BUILD_NAME || `Local - ${user} - ${date}`, - sessionName: 'iOS Safari Tests', - // The device reaches the dev server over the public cloudflared HTTPS URL (onPrepare), so - // BrowserStack Local (`local: true`) is not used on this path. These flags collect diagnostic - // data on BrowserStack's web dashboard, which we don't need/use. - debug: false, - networkLogs: false, - consoleLogs: 'errors', - idleTimeout: 60, - }, - }, - ], + capabilities: [suiteCapability, caretAdjacentTapCapability] as WebdriverIO.Config['capabilities'], // Services services: [ diff --git a/src/e2e/iOS/helpers/doubleTap.ts b/src/e2e/iOS/helpers/doubleTap.ts index ac706b6079e..d65261da5e3 100644 --- a/src/e2e/iOS/helpers/doubleTap.ts +++ b/src/e2e/iOS/helpers/doubleTap.ts @@ -8,6 +8,11 @@ interface Options { holdMs?: number } +// Finger-sized contact area for the touch. A zero-radius synthetic tap does not trigger Safari's +// touch-adjustment heuristic; only a real contact area (with pressure) is processed like a physical +// finger. See https://github.com/cybersemics/em/pull/4407. +const CONTACT = { width: 40, height: 40, pressure: 0.9 } + /** * Computes the center point of an element in native screen coordinates. * Touch pointer actions on iOS Safari (XCUITest) are interpreted in screen coordinates, unlike the @@ -27,34 +32,42 @@ const centerOf = async (nodeHandle: Element) => { /** * Taps two elements in quick succession with a single touch pointer, with a precisely controlled - * interval between the two taps. Both element coordinates are resolved up front so that the gap - * between the taps is exactly intervalMs rather than incidental round-trip latency (which can exceed - * a second and is the reason a hand-driven repro of #4173 is unreliable). Uses a real touch pointer - * (pointerType: 'touch') so WebKit's rapid-tap gesture recognition is exercised the same way a - * physical double tap would exercise it. See https://github.com/cybersemics/em/issues/4173. + * interval between the two taps. Both element coordinates are resolved up front (in the webview + * context) so that the gap between the taps is exactly intervalMs rather than incidental round-trip + * latency, which can exceed a second and is the reason a hand-driven repro of #4173 is unreliable. + * + * The taps are dispatched from the NATIVE_APP context using a finger-sized contact area + * (width/height/pressure), so iOS processes them like a physical double tap and Safari's + * touch-adjustment / rapid-tap focus handling is exercised the same way real hardware exercises it. + * A zero-radius webview-context tap bypasses that native handling and cannot reproduce #4173. + * See https://github.com/cybersemics/em/issues/4173 and https://github.com/cybersemics/em/pull/4407. */ const doubleTap = async (first: Element, second: Element, { intervalMs = 300, holdMs = 60 }: Options = {}) => { + // Resolve both centers while still in the webview context (element handles are webview-scoped). const firstPoint = await centerOf(first) const secondPoint = await centerOf(second) + const webviewContext = (await browser.getContext()) as string + await browser.switchContext('NATIVE_APP') await browser.performActions([ { type: 'pointer', id: 'finger1', parameters: { pointerType: 'touch' }, actions: [ - { type: 'pointerMove', duration: 0, x: firstPoint.x, y: firstPoint.y, origin: 'viewport' }, - { type: 'pointerDown', button: 0 }, + { type: 'pointerMove', duration: 0, x: firstPoint.x, y: firstPoint.y, origin: 'viewport', ...CONTACT }, + { type: 'pointerDown', button: 0, ...CONTACT }, { type: 'pause', duration: holdMs }, { type: 'pointerUp', button: 0 }, { type: 'pause', duration: intervalMs }, - { type: 'pointerMove', duration: 0, x: secondPoint.x, y: secondPoint.y, origin: 'viewport' }, - { type: 'pointerDown', button: 0 }, + { type: 'pointerMove', duration: 0, x: secondPoint.x, y: secondPoint.y, origin: 'viewport', ...CONTACT }, + { type: 'pointerDown', button: 0, ...CONTACT }, { type: 'pause', duration: holdMs }, { type: 'pointerUp', button: 0 }, ], }, ]) + await browser.switchContext(webviewContext) } export default doubleTap From fe12df0b770537bf002c0773cc125fee3b469465 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Tue, 7 Jul 2026 12:54:57 -0700 Subject: [PATCH 03/18] test(ios): drop touch pressure to avoid force-press error on iPhone 16 The iPhone 16 has no Force Touch hardware, so the `pressure: 0.9` field made WebDriverAgent attempt a force-press and fail the whole tap with "This device does not support force press interactions". Keep the finger-sized contact radius (width/height), which is the part that drives Safari's touch-adjustment retargeting; pressure is not needed for it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/e2e/iOS/helpers/doubleTap.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/e2e/iOS/helpers/doubleTap.ts b/src/e2e/iOS/helpers/doubleTap.ts index d65261da5e3..99490d31b6b 100644 --- a/src/e2e/iOS/helpers/doubleTap.ts +++ b/src/e2e/iOS/helpers/doubleTap.ts @@ -9,9 +9,12 @@ interface Options { } // Finger-sized contact area for the touch. A zero-radius synthetic tap does not trigger Safari's -// touch-adjustment heuristic; only a real contact area (with pressure) is processed like a physical -// finger. See https://github.com/cybersemics/em/pull/4407. -const CONTACT = { width: 40, height: 40, pressure: 0.9 } +// touch-adjustment heuristic; only a real contact radius is processed like a physical finger (see +// https://github.com/cybersemics/em/pull/4407). Pressure is intentionally omitted: the iPhone 16 +// hardware has no Force Touch, and a pressure field makes WDA attempt a force-press, which fails with +// "This device does not support force press interactions". The contact radius alone drives the +// touch-adjustment retargeting. +const CONTACT = { width: 40, height: 40 } /** * Computes the center point of an element in native screen coordinates. From c53c83f5d5e1e96549c95e32a3da12ce5b39d946 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Tue, 7 Jul 2026 13:04:05 -0700 Subject: [PATCH 04/18] test(ios): sweep inter-tap intervals to probe #4173 trigger window Parametrizes the adjacent-tap spec over a range of inter-tap intervals (120/200/300/500/800ms) so a single BrowserStack session probes the whole window. #4173 occurs "within 1 second" of the first tap and may only surface for a subset of intervals near WebKit's double-tap detection threshold. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/e2e/iOS/__tests__/caretAdjacentTap.ts | 48 ++++++++++++----------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/src/e2e/iOS/__tests__/caretAdjacentTap.ts b/src/e2e/iOS/__tests__/caretAdjacentTap.ts index aea653d274f..daad64e51e9 100644 --- a/src/e2e/iOS/__tests__/caretAdjacentTap.ts +++ b/src/e2e/iOS/__tests__/caretAdjacentTap.ts @@ -10,35 +10,39 @@ import getEditingText from '../helpers/getEditingText' import paste from '../helpers/paste' import waitForEditable from '../helpers/waitForEditable' -describe('Caret', () => { - it('Set caret on adjacent thought within 1 second (#4173)', async () => { - // Reproduces https://github.com/cybersemics/em/issues/4173: tapping an adjacent thought within - // ~1 second of a previous tap fails to move the cursor (the second tap's setCursor is not - // dispatched and focus is not triggered on the ContentEditable). A non-adjacent thought works. - // doubleTap fires two real, finger-sized native touch taps with a deterministic sub-second - // interval, which a hand-driven repro cannot reliably achieve. The interval is overridable via - // TAP_INTERVAL_MS to probe the threshold. - const intervalMs = process.env.TAP_INTERVAL_MS ? parseInt(process.env.TAP_INTERVAL_MS, 10) : 300 +// Sweep of inter-tap intervals (ms) to probe the #4173 trigger window. The issue describes the +// failure occurring "within 1 second" of the first tap, and WebKit's double-tap-to-zoom detection +// sits around ~300-350ms, so the bug may only surface for a subset of these intervals. +const INTERVALS_MS = [120, 200, 300, 500, 800] - const importText = ` +describe('Caret', () => { + INTERVALS_MS.forEach(intervalMs => { + it(`Set caret on adjacent thought ${intervalMs}ms after tap (#4173)`, async () => { + // Reproduces https://github.com/cybersemics/em/issues/4173: tapping an adjacent thought within + // ~1 second of a previous tap fails to move the cursor (the second tap's setCursor is not + // dispatched and focus is not triggered on the ContentEditable). A non-adjacent thought works. + // doubleTap fires two real, finger-sized native touch taps with a deterministic interval, which + // a hand-driven repro cannot reliably achieve. + const importText = ` - a - b - c` - await paste(importText) - await waitForEditable('c') + await paste(importText) + await waitForEditable('c') - // Stabilize layout so the touch coordinates resolve against a settled DOM. - await browser.execute(() => window.scrollTo(0, 0)) - await browser.pause(400) + // Stabilize layout so the touch coordinates resolve against a settled DOM. + await browser.execute(() => window.scrollTo(0, 0)) + await browser.pause(400) - const a = await waitForEditable('a') - const b = await waitForEditable('b') + const a = await waitForEditable('a') + const b = await waitForEditable('b') - // Tap a to set the caret (and open the keyboard), then within intervalMs tap the adjacent b. - await doubleTap(a, b, { intervalMs }) + // Tap a to set the caret (and open the keyboard), then within intervalMs tap the adjacent b. + await doubleTap(a, b, { intervalMs }) - // Give the app a moment to dispatch setCursor and re-render, then assert the cursor moved to b. - await browser.pause(1000) - expect(await getEditingText()).toBe('b') + // Give the app a moment to dispatch setCursor and re-render, then assert the cursor moved to b. + await browser.pause(1000) + expect(await getEditingText()).toBe('b') + }) }) }) From 86eba7cd7afe542a24b2ade074e96becb6961e78 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Tue, 7 Jul 2026 13:18:02 -0700 Subject: [PATCH 05/18] test(ios): reproduce #4173 via native WebKit taps (mobile: tap) Switches the adjacent-tap repro from performActions HID synthesis to Appium's `mobile: tap`, following the established showEditMenu.ts pattern: a native tap issued from the webview context with getBoundingClientRect coordinates is processed through WebKit's touch/gesture recognizers - the path #4173 depends on. Sweeps the inter-tap interval and logs the measured gap. Removes the now unused doubleTap performActions helper. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/e2e/iOS/__tests__/caretAdjacentTap.ts | 34 ++++++++-- src/e2e/iOS/helpers/doubleTap.ts | 76 ----------------------- 2 files changed, 28 insertions(+), 82 deletions(-) delete mode 100644 src/e2e/iOS/helpers/doubleTap.ts diff --git a/src/e2e/iOS/__tests__/caretAdjacentTap.ts b/src/e2e/iOS/__tests__/caretAdjacentTap.ts index daad64e51e9..0828e676717 100644 --- a/src/e2e/iOS/__tests__/caretAdjacentTap.ts +++ b/src/e2e/iOS/__tests__/caretAdjacentTap.ts @@ -5,7 +5,7 @@ * / rapid-tap focus handling reproduces the bug (see wdio.browserstack.conf.ts). The rest of the iOS * suite runs on the default device/version. */ -import doubleTap from '../helpers/doubleTap' +import type { Element } from 'webdriverio' import getEditingText from '../helpers/getEditingText' import paste from '../helpers/paste' import waitForEditable from '../helpers/waitForEditable' @@ -13,7 +13,24 @@ import waitForEditable from '../helpers/waitForEditable' // Sweep of inter-tap intervals (ms) to probe the #4173 trigger window. The issue describes the // failure occurring "within 1 second" of the first tap, and WebKit's double-tap-to-zoom detection // sits around ~300-350ms, so the bug may only surface for a subset of these intervals. -const INTERVALS_MS = [120, 200, 300, 500, 800] +const INTERVALS_MS = [0, 150, 300, 500] + +/** + * Taps the center of an editable with a native WebKit touch via Appium's `mobile: tap`. Unlike + * `performActions` (which synthesizes low-level HID events at the WebDriver layer), `mobile: tap` is + * processed through WebKit's touch/gesture recognizers - the same path a physical finger drives, and + * the path #4173 depends on. Coordinates are webview CSS pixels, resolved fresh from + * getBoundingClientRect right before the tap so any keyboard-induced layout shift is accounted for. + * This mirrors the established helper src/e2e/iOS/helpers/showEditMenu.ts. + */ +const nativeTapCenter = async (editable: Element): Promise => { + const raw = await browser.execute(el => { + const rect = (el as unknown as HTMLElement).getBoundingClientRect() + return JSON.stringify({ x: Math.round(rect.x + rect.width / 2), y: Math.round(rect.y + rect.height / 2) }) + }, editable) + const { x, y } = JSON.parse(raw) as { x: number; y: number } + await browser.execute('mobile: tap', { x, y }) +} describe('Caret', () => { INTERVALS_MS.forEach(intervalMs => { @@ -21,8 +38,6 @@ describe('Caret', () => { // Reproduces https://github.com/cybersemics/em/issues/4173: tapping an adjacent thought within // ~1 second of a previous tap fails to move the cursor (the second tap's setCursor is not // dispatched and focus is not triggered on the ContentEditable). A non-adjacent thought works. - // doubleTap fires two real, finger-sized native touch taps with a deterministic interval, which - // a hand-driven repro cannot reliably achieve. const importText = ` - a - b @@ -37,8 +52,15 @@ describe('Caret', () => { const a = await waitForEditable('a') const b = await waitForEditable('b') - // Tap a to set the caret (and open the keyboard), then within intervalMs tap the adjacent b. - await doubleTap(a, b, { intervalMs }) + // Tap a to set the caret (and open the keyboard), then within intervalMs tap the adjacent b, + // using native WebKit touches. Measure the wall-clock gap between the two taps for diagnostics. + const t0 = Date.now() + await nativeTapCenter(a) + const t1 = Date.now() + if (intervalMs) await browser.pause(intervalMs) + await nativeTapCenter(b) + const t2 = Date.now() + console.info(`#4173 native-tap timing: first tap ${t1 - t0}ms, a->b gap ~${t2 - t1}ms (interval ${intervalMs}ms)`) // Give the app a moment to dispatch setCursor and re-render, then assert the cursor moved to b. await browser.pause(1000) diff --git a/src/e2e/iOS/helpers/doubleTap.ts b/src/e2e/iOS/helpers/doubleTap.ts deleted file mode 100644 index 99490d31b6b..00000000000 --- a/src/e2e/iOS/helpers/doubleTap.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { Element } from 'webdriverio' -import getElementRectByScreen from './getElementRectByScreen.js' - -interface Options { - // Milliseconds to pause between the release of the first tap and the press of the second tap. - intervalMs?: number - // Milliseconds each tap is held down before release. - holdMs?: number -} - -// Finger-sized contact area for the touch. A zero-radius synthetic tap does not trigger Safari's -// touch-adjustment heuristic; only a real contact radius is processed like a physical finger (see -// https://github.com/cybersemics/em/pull/4407). Pressure is intentionally omitted: the iPhone 16 -// hardware has no Force Touch, and a pressure field makes WDA attempt a force-press, which fails with -// "This device does not support force press interactions". The contact radius alone drives the -// touch-adjustment retargeting. -const CONTACT = { width: 40, height: 40 } - -/** - * Computes the center point of an element in native screen coordinates. - * Touch pointer actions on iOS Safari (XCUITest) are interpreted in screen coordinates, unlike the - * mouse pointer used by the `tap` helper which uses web viewport coordinates. The screen rect - * adds the native Safari content offset so the touch lands on the element. - */ -const centerOf = async (nodeHandle: Element) => { - const exists = await nodeHandle.isExisting() - if (!exists) throw new Error('Element does not exist in the DOM.') - const rect = await getElementRectByScreen(nodeHandle) - if (!rect) throw new Error('Bounding box of element not found.') - return { - x: Math.round(rect.x + rect.width / 2), - y: Math.round(rect.y + rect.height / 2), - } -} - -/** - * Taps two elements in quick succession with a single touch pointer, with a precisely controlled - * interval between the two taps. Both element coordinates are resolved up front (in the webview - * context) so that the gap between the taps is exactly intervalMs rather than incidental round-trip - * latency, which can exceed a second and is the reason a hand-driven repro of #4173 is unreliable. - * - * The taps are dispatched from the NATIVE_APP context using a finger-sized contact area - * (width/height/pressure), so iOS processes them like a physical double tap and Safari's - * touch-adjustment / rapid-tap focus handling is exercised the same way real hardware exercises it. - * A zero-radius webview-context tap bypasses that native handling and cannot reproduce #4173. - * See https://github.com/cybersemics/em/issues/4173 and https://github.com/cybersemics/em/pull/4407. - */ -const doubleTap = async (first: Element, second: Element, { intervalMs = 300, holdMs = 60 }: Options = {}) => { - // Resolve both centers while still in the webview context (element handles are webview-scoped). - const firstPoint = await centerOf(first) - const secondPoint = await centerOf(second) - - const webviewContext = (await browser.getContext()) as string - await browser.switchContext('NATIVE_APP') - await browser.performActions([ - { - type: 'pointer', - id: 'finger1', - parameters: { pointerType: 'touch' }, - actions: [ - { type: 'pointerMove', duration: 0, x: firstPoint.x, y: firstPoint.y, origin: 'viewport', ...CONTACT }, - { type: 'pointerDown', button: 0, ...CONTACT }, - { type: 'pause', duration: holdMs }, - { type: 'pointerUp', button: 0 }, - { type: 'pause', duration: intervalMs }, - { type: 'pointerMove', duration: 0, x: secondPoint.x, y: secondPoint.y, origin: 'viewport', ...CONTACT }, - { type: 'pointerDown', button: 0, ...CONTACT }, - { type: 'pause', duration: holdMs }, - { type: 'pointerUp', button: 0 }, - ], - }, - ]) - await browser.switchContext(webviewContext) -} - -export default doubleTap From 9a9597047651c660c2b7be8c91b85d298452e4aa Mon Sep 17 00:00:00 2001 From: Ethan James Date: Tue, 7 Jul 2026 13:31:43 -0700 Subject: [PATCH 06/18] test(ios): use screen coords for native mobile: tap in #4173 repro mobile: tap without an element uses absolute screen coordinates, so the previous getBoundingClientRect (webview CSS) coords landed above the thoughts in the Safari chrome and the taps missed (cursor stayed on the post-paste thought c). Switch to getElementRectByScreen, which adds the native Safari content offset - the same screen-coordinate space that landed correctly with performActions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/e2e/iOS/__tests__/caretAdjacentTap.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/e2e/iOS/__tests__/caretAdjacentTap.ts b/src/e2e/iOS/__tests__/caretAdjacentTap.ts index 0828e676717..8a6432e324e 100644 --- a/src/e2e/iOS/__tests__/caretAdjacentTap.ts +++ b/src/e2e/iOS/__tests__/caretAdjacentTap.ts @@ -7,6 +7,7 @@ */ import type { Element } from 'webdriverio' import getEditingText from '../helpers/getEditingText' +import getElementRectByScreen from '../helpers/getElementRectByScreen' import paste from '../helpers/paste' import waitForEditable from '../helpers/waitForEditable' @@ -19,16 +20,14 @@ const INTERVALS_MS = [0, 150, 300, 500] * Taps the center of an editable with a native WebKit touch via Appium's `mobile: tap`. Unlike * `performActions` (which synthesizes low-level HID events at the WebDriver layer), `mobile: tap` is * processed through WebKit's touch/gesture recognizers - the same path a physical finger drives, and - * the path #4173 depends on. Coordinates are webview CSS pixels, resolved fresh from - * getBoundingClientRect right before the tap so any keyboard-induced layout shift is accounted for. - * This mirrors the established helper src/e2e/iOS/helpers/showEditMenu.ts. + * the path #4173 depends on. `mobile: tap` with no element uses absolute *screen* coordinates, so the + * center is taken from getElementRectByScreen (which adds the native Safari content offset), not from + * getBoundingClientRect (webview CSS coordinates, which would land in the browser chrome above). */ const nativeTapCenter = async (editable: Element): Promise => { - const raw = await browser.execute(el => { - const rect = (el as unknown as HTMLElement).getBoundingClientRect() - return JSON.stringify({ x: Math.round(rect.x + rect.width / 2), y: Math.round(rect.y + rect.height / 2) }) - }, editable) - const { x, y } = JSON.parse(raw) as { x: number; y: number } + const rect = await getElementRectByScreen(editable) + const x = Math.round(rect.x + rect.width / 2) + const y = Math.round(rect.y + rect.height / 2) await browser.execute('mobile: tap', { x, y }) } From a555192eca6dc53b391b85feced7f2f72ed7960f Mon Sep 17 00:00:00 2001 From: Ethan James Date: Tue, 7 Jul 2026 13:46:10 -0700 Subject: [PATCH 07/18] test(ios): pre-resolve tap coords to hit #4173 sub-second window The previous run passed at every interval, but the logged a->b gap was ~3s even at interval 0ms: getElementRectByScreen switches to the NATIVE_APP context and back, and doing that for b *between* the two taps injected ~3s of latency - pushing the second tap far outside #4173's ~1s window, so the bug could never surface. Prime the keyboard with an initial tap on a (which also settles the layout that opening the keyboard shifts), then pre-resolve BOTH screen coordinates up-front, then fire the measured rapid sequence (tap a, pause interval, tap b) with no context switch in between. Now the a->b gap reflects only intervalMs plus a single mobile:tap execution, actually exercising the sub-second window. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/e2e/iOS/__tests__/caretAdjacentTap.ts | 51 +++++++++++++++++------ 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/src/e2e/iOS/__tests__/caretAdjacentTap.ts b/src/e2e/iOS/__tests__/caretAdjacentTap.ts index 8a6432e324e..f5e932e4f92 100644 --- a/src/e2e/iOS/__tests__/caretAdjacentTap.ts +++ b/src/e2e/iOS/__tests__/caretAdjacentTap.ts @@ -16,18 +16,31 @@ import waitForEditable from '../helpers/waitForEditable' // sits around ~300-350ms, so the bug may only surface for a subset of these intervals. const INTERVALS_MS = [0, 150, 300, 500] +/** Absolute device-screen coordinate for a native `mobile: tap`. */ +interface ScreenPoint { + x: number + y: number +} + +/** + * Resolve the absolute device-screen center of an editable. `mobile: tap` with no element uses + * absolute *screen* coordinates, so the center is taken from getElementRectByScreen (which adds the + * native Safari content offset), not from getBoundingClientRect (webview CSS coordinates, which would + * land in the browser chrome above). This performs a NATIVE_APP context switch and is therefore slow + * (~1-2s); it MUST be done up-front, never between the two measured taps (see below). + */ +const screenCenter = async (editable: Element): Promise => { + const rect = await getElementRectByScreen(editable) + return { x: Math.round(rect.x + rect.width / 2), y: Math.round(rect.y + rect.height / 2) } +} + /** - * Taps the center of an editable with a native WebKit touch via Appium's `mobile: tap`. Unlike + * Tap an absolute device-screen point with a native WebKit touch via Appium's `mobile: tap`. Unlike * `performActions` (which synthesizes low-level HID events at the WebDriver layer), `mobile: tap` is * processed through WebKit's touch/gesture recognizers - the same path a physical finger drives, and - * the path #4173 depends on. `mobile: tap` with no element uses absolute *screen* coordinates, so the - * center is taken from getElementRectByScreen (which adds the native Safari content offset), not from - * getBoundingClientRect (webview CSS coordinates, which would land in the browser chrome above). + * the path #4173 depends on. Coordinates are pre-resolved so this call adds no context-switch latency. */ -const nativeTapCenter = async (editable: Element): Promise => { - const rect = await getElementRectByScreen(editable) - const x = Math.round(rect.x + rect.width / 2) - const y = Math.round(rect.y + rect.height / 2) +const nativeTap = async ({ x, y }: ScreenPoint): Promise => { await browser.execute('mobile: tap', { x, y }) } @@ -51,13 +64,27 @@ describe('Caret', () => { const a = await waitForEditable('a') const b = await waitForEditable('b') - // Tap a to set the caret (and open the keyboard), then within intervalMs tap the adjacent b, - // using native WebKit touches. Measure the wall-clock gap between the two taps for diagnostics. + // Prime the keyboard: tapping a thought opens the on-screen keyboard, which shifts the layout. + // We must resolve the tap coordinates AFTER that shift settles, otherwise b's pre-keyboard + // coordinate is stale. This priming tap also puts us in the real #4173 scenario (caret already + // on a thought, keyboard open) before the measured rapid two-tap sequence. + await nativeTap(await screenCenter(a)) + await browser.pause(700) + + // Pre-resolve BOTH screen coordinates now (each does a slow NATIVE_APP context switch). Doing + // this up-front is essential: resolving b's rect *between* the two taps injected ~3s of latency, + // pushing the second tap far outside #4173's sub-second window and masking the bug. + const aPoint = await screenCenter(a) + const bPoint = await screenCenter(b) + + // Rapid sequence: re-tap a, then within intervalMs tap the adjacent b, using native WebKit + // touches. The only work between the taps is the interval pause, so the measured a->b gap + // reflects intervalMs plus a single mobile:tap execution (no context switch). const t0 = Date.now() - await nativeTapCenter(a) + await nativeTap(aPoint) const t1 = Date.now() if (intervalMs) await browser.pause(intervalMs) - await nativeTapCenter(b) + await nativeTap(bPoint) const t2 = Date.now() console.info(`#4173 native-tap timing: first tap ${t1 - t0}ms, a->b gap ~${t2 - t1}ms (interval ${intervalMs}ms)`) From cc3de77b1148a181fa8fd804b7522fdfc971d234 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Tue, 7 Jul 2026 14:01:23 -0700 Subject: [PATCH 08/18] test(ios): cache Safari offset so both taps land within #4173 window The pre-resolve variant hit sub-second gaps but every tap missed (cursor stuck on c): the two back-to-back NATIVE_APP context switches used to resolve the screen coordinates disturbed the webview/keyboard state, so the coordinates went stale. The Safari webview container's screen offset is constant, so read it once via a single native switch, then compute each editable's screen center from its fast webview-context rect plus that cached offset. This drops the priming tap and the per-tap context switches: a's and b's rects are read fresh (b's just before its tap, so it reflects the keyboard-open layout) with no switch between the two taps, keeping the a->b gap in the sub-second window while landing the taps accurately. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/e2e/iOS/__tests__/caretAdjacentTap.ts | 53 ++++++++++------------- 1 file changed, 23 insertions(+), 30 deletions(-) diff --git a/src/e2e/iOS/__tests__/caretAdjacentTap.ts b/src/e2e/iOS/__tests__/caretAdjacentTap.ts index f5e932e4f92..6ad164a8441 100644 --- a/src/e2e/iOS/__tests__/caretAdjacentTap.ts +++ b/src/e2e/iOS/__tests__/caretAdjacentTap.ts @@ -7,7 +7,7 @@ */ import type { Element } from 'webdriverio' import getEditingText from '../helpers/getEditingText' -import getElementRectByScreen from '../helpers/getElementRectByScreen' +import getNativeElementRect from '../helpers/getNativeElementRect' import paste from '../helpers/paste' import waitForEditable from '../helpers/waitForEditable' @@ -22,23 +22,11 @@ interface ScreenPoint { y: number } -/** - * Resolve the absolute device-screen center of an editable. `mobile: tap` with no element uses - * absolute *screen* coordinates, so the center is taken from getElementRectByScreen (which adds the - * native Safari content offset), not from getBoundingClientRect (webview CSS coordinates, which would - * land in the browser chrome above). This performs a NATIVE_APP context switch and is therefore slow - * (~1-2s); it MUST be done up-front, never between the two measured taps (see below). - */ -const screenCenter = async (editable: Element): Promise => { - const rect = await getElementRectByScreen(editable) - return { x: Math.round(rect.x + rect.width / 2), y: Math.round(rect.y + rect.height / 2) } -} - /** * Tap an absolute device-screen point with a native WebKit touch via Appium's `mobile: tap`. Unlike * `performActions` (which synthesizes low-level HID events at the WebDriver layer), `mobile: tap` is * processed through WebKit's touch/gesture recognizers - the same path a physical finger drives, and - * the path #4173 depends on. Coordinates are pre-resolved so this call adds no context-switch latency. + * the path #4173 depends on. `mobile: tap` with no element uses absolute *screen* coordinates. */ const nativeTap = async ({ x, y }: ScreenPoint): Promise => { await browser.execute('mobile: tap', { x, y }) @@ -64,27 +52,32 @@ describe('Caret', () => { const a = await waitForEditable('a') const b = await waitForEditable('b') - // Prime the keyboard: tapping a thought opens the on-screen keyboard, which shifts the layout. - // We must resolve the tap coordinates AFTER that shift settles, otherwise b's pre-keyboard - // coordinate is stale. This priming tap also puts us in the real #4173 scenario (caret already - // on a thought, keyboard open) before the measured rapid two-tap sequence. - await nativeTap(await screenCenter(a)) - await browser.pause(700) + // Cache the Safari webview container's screen offset ONCE. `mobile: tap` uses absolute screen + // coordinates, so each editable's on-screen center is its webview rect plus this offset. The + // offset is a property of the browser chrome layout, not the page, so it is constant across the + // two taps - caching it lets us re-read each editable's (fast, webview-context) rect without a + // NATIVE_APP context switch between the taps. That switch previously injected ~3s of latency, + // pushing the second tap outside #4173's sub-second window and masking the bug. + const offset = await getNativeElementRect('//XCUIElementTypeOther[@name="em"]') - // Pre-resolve BOTH screen coordinates now (each does a slow NATIVE_APP context switch). Doing - // this up-front is essential: resolving b's rect *between* the two taps injected ~3s of latency, - // pushing the second tap far outside #4173's sub-second window and masking the bug. - const aPoint = await screenCenter(a) - const bPoint = await screenCenter(b) + /** Absolute screen center of an editable, using the cached container offset (no context switch). */ + const screenCenter = async (editable: Element): Promise => { + const rect = await browser.getElementRect(editable.elementId) + return { + x: Math.round(rect.x + offset.x + rect.width / 2), + y: Math.round(rect.y + offset.y + rect.height / 2), + } + } - // Rapid sequence: re-tap a, then within intervalMs tap the adjacent b, using native WebKit - // touches. The only work between the taps is the interval pause, so the measured a->b gap - // reflects intervalMs plus a single mobile:tap execution (no context switch). + // Tap a to set the caret (and open the keyboard), then within intervalMs tap the adjacent b, + // using native WebKit touches. b's rect is re-read just before its tap so it reflects any layout + // shift from the keyboard opening; because that read stays in the webview context it is fast, so + // the measured a->b gap reflects intervalMs plus lightweight rect reads and mobile:tap execution. const t0 = Date.now() - await nativeTap(aPoint) + await nativeTap(await screenCenter(a)) const t1 = Date.now() if (intervalMs) await browser.pause(intervalMs) - await nativeTap(bPoint) + await nativeTap(await screenCenter(b)) const t2 = Date.now() console.info(`#4173 native-tap timing: first tap ${t1 - t0}ms, a->b gap ~${t2 - t1}ms (interval ${intervalMs}ms)`) From 89a538cda3bbb27567e0fe864f4c9659b04b7a54 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Tue, 7 Jul 2026 14:31:41 -0700 Subject: [PATCH 09/18] test(ios): single-chain two-tap to probe #4173 double-tap window Sequential mobile: tap calls have a ~751ms floor, above WebKit's double-tap window (~300-350ms), so they could never exercise the sub-350ms regime. Dispatch both taps in ONE atomic performActions chain whose in-chain pause is the exact inter-tap gap, sweeping [0,100,200,300]ms - the only way to probe the double-tap window at two distinct points (a then b). Coordinates use the proven cached-Safari-offset screen centers; both are resolved up front while the keyboard is still closed, which is correct because both taps fire within the sub-second chain before the keyboard opens. Finger-sized contact (no pressure) drives touch-adjustment like a physical tap; short hold keeps each tap crisp for rapid-tap detection. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/e2e/iOS/__tests__/caretAdjacentTap.ts | 90 ++++++++++++++--------- 1 file changed, 56 insertions(+), 34 deletions(-) diff --git a/src/e2e/iOS/__tests__/caretAdjacentTap.ts b/src/e2e/iOS/__tests__/caretAdjacentTap.ts index 6ad164a8441..35282d578de 100644 --- a/src/e2e/iOS/__tests__/caretAdjacentTap.ts +++ b/src/e2e/iOS/__tests__/caretAdjacentTap.ts @@ -11,30 +11,33 @@ import getNativeElementRect from '../helpers/getNativeElementRect' import paste from '../helpers/paste' import waitForEditable from '../helpers/waitForEditable' -// Sweep of inter-tap intervals (ms) to probe the #4173 trigger window. The issue describes the -// failure occurring "within 1 second" of the first tap, and WebKit's double-tap-to-zoom detection -// sits around ~300-350ms, so the bug may only surface for a subset of these intervals. -const INTERVALS_MS = [0, 150, 300, 500] +// Sub-second inter-tap gaps (ms) that fall at or below WebKit's double-tap-to-zoom detection window +// (~300-350ms). Sequential `mobile: tap` calls cannot reach this window (each has a ~375ms WDA +// round-trip, a ~751ms floor for two taps), so the two taps are dispatched here as a SINGLE atomic +// performActions chain whose in-chain pause is the exact gap - the only way to probe the double-tap +// window at two distinct points. +const GAPS_MS = [0, 100, 200, 300] -/** Absolute device-screen coordinate for a native `mobile: tap`. */ +// Milliseconds each tap is held down before release. Kept short so each tap reads as a crisp quick +// tap, the kind WebKit's rapid-tap / double-tap detection responds to. +const HOLD_MS = 20 + +// Finger-sized contact area for the touch. A zero-radius synthetic tap does not trigger Safari's +// touch-adjustment heuristic; only a real contact radius is processed like a physical finger (see +// https://github.com/cybersemics/em/pull/4407). Pressure is intentionally omitted: the iPhone 16 +// hardware has no Force Touch, and a pressure field makes WDA attempt a force-press, which fails with +// "This device does not support force press interactions". +const CONTACT = { width: 40, height: 40 } + +/** Absolute device-screen coordinate for a native touch. */ interface ScreenPoint { x: number y: number } -/** - * Tap an absolute device-screen point with a native WebKit touch via Appium's `mobile: tap`. Unlike - * `performActions` (which synthesizes low-level HID events at the WebDriver layer), `mobile: tap` is - * processed through WebKit's touch/gesture recognizers - the same path a physical finger drives, and - * the path #4173 depends on. `mobile: tap` with no element uses absolute *screen* coordinates. - */ -const nativeTap = async ({ x, y }: ScreenPoint): Promise => { - await browser.execute('mobile: tap', { x, y }) -} - describe('Caret', () => { - INTERVALS_MS.forEach(intervalMs => { - it(`Set caret on adjacent thought ${intervalMs}ms after tap (#4173)`, async () => { + GAPS_MS.forEach(gapMs => { + it(`Set caret on adjacent thought ${gapMs}ms after tap (#4173)`, async () => { // Reproduces https://github.com/cybersemics/em/issues/4173: tapping an adjacent thought within // ~1 second of a previous tap fails to move the cursor (the second tap's setCursor is not // dispatched and focus is not triggered on the ContentEditable). A non-adjacent thought works. @@ -52,15 +55,11 @@ describe('Caret', () => { const a = await waitForEditable('a') const b = await waitForEditable('b') - // Cache the Safari webview container's screen offset ONCE. `mobile: tap` uses absolute screen - // coordinates, so each editable's on-screen center is its webview rect plus this offset. The - // offset is a property of the browser chrome layout, not the page, so it is constant across the - // two taps - caching it lets us re-read each editable's (fast, webview-context) rect without a - // NATIVE_APP context switch between the taps. That switch previously injected ~3s of latency, - // pushing the second tap outside #4173's sub-second window and masking the bug. + // Cache the Safari webview container's screen offset ONCE (native touches use absolute screen + // coordinates). The offset is a property of the browser chrome, not the page, so it is constant. const offset = await getNativeElementRect('//XCUIElementTypeOther[@name="em"]') - /** Absolute screen center of an editable, using the cached container offset (no context switch). */ + /** Absolute screen center of an editable, using the cached container offset. */ const screenCenter = async (editable: Element): Promise => { const rect = await browser.getElementRect(editable.elementId) return { @@ -69,17 +68,40 @@ describe('Caret', () => { } } - // Tap a to set the caret (and open the keyboard), then within intervalMs tap the adjacent b, - // using native WebKit touches. b's rect is re-read just before its tap so it reflects any layout - // shift from the keyboard opening; because that read stays in the webview context it is fast, so - // the measured a->b gap reflects intervalMs plus lightweight rect reads and mobile:tap execution. + // Resolve both centers up front, while the keyboard is still closed. Because both taps fire in a + // single sub-second action chain (below), the keyboard has not yet opened when b is tapped, so + // b's pre-keyboard coordinate is the correct one at chain-execution time. + const aPoint = await screenCenter(a) + const bPoint = await screenCenter(b) + + // Dispatch BOTH taps in one atomic performActions chain from the NATIVE_APP context, with a + // finger-sized contact area so iOS processes them like physical taps. The in-chain pause is the + // exact inter-tap gap, letting us probe WebKit's double-tap window (<=300ms) that two sequential + // `mobile: tap` calls cannot reach. + const webviewContext = (await browser.getContext()) as string + await browser.switchContext('NATIVE_APP') const t0 = Date.now() - await nativeTap(await screenCenter(a)) - const t1 = Date.now() - if (intervalMs) await browser.pause(intervalMs) - await nativeTap(await screenCenter(b)) - const t2 = Date.now() - console.info(`#4173 native-tap timing: first tap ${t1 - t0}ms, a->b gap ~${t2 - t1}ms (interval ${intervalMs}ms)`) + await browser.performActions([ + { + type: 'pointer', + id: 'finger1', + parameters: { pointerType: 'touch' }, + actions: [ + { type: 'pointerMove', duration: 0, x: aPoint.x, y: aPoint.y, origin: 'viewport', ...CONTACT }, + { type: 'pointerDown', button: 0, ...CONTACT }, + { type: 'pause', duration: HOLD_MS }, + { type: 'pointerUp', button: 0 }, + { type: 'pause', duration: gapMs }, + { type: 'pointerMove', duration: 0, x: bPoint.x, y: bPoint.y, origin: 'viewport', ...CONTACT }, + { type: 'pointerDown', button: 0, ...CONTACT }, + { type: 'pause', duration: HOLD_MS }, + { type: 'pointerUp', button: 0 }, + ], + }, + ]) + const elapsed = Date.now() - t0 + await browser.switchContext(webviewContext) + console.info(`#4173 single-chain two-tap: chain took ${elapsed}ms (gap ${gapMs}ms, hold ${HOLD_MS}ms)`) // Give the app a moment to dispatch setCursor and re-render, then assert the cursor moved to b. await browser.pause(1000) From b44a6747d97ef7dbdb7c071c0f715a3957b01608 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Tue, 7 Jul 2026 14:54:40 -0700 Subject: [PATCH 10/18] test(ios): add non-adjacent control + boundary sweep to verify #4173 repro The single-chain two-tap reproduces #4173's symptom (cursor stuck on a) only at a 0ms gap and passes at >=100ms, which is inconsistent with the manually reported "within a second" window. That raises the question of whether the 0ms failure is a faithful repro or a synthetic-input artifact (two ultra-fast touches coalesced / the second dropped). Add a discriminating control: at the SAME 0ms timing, tap a->c (non-adjacent), which per the issue must still move the cursor. If a->b fails at 0ms but a->c passes at 0ms, the bug is adjacency-specific (genuine #4173); if a->c also fails, the second tap is merely being dropped (artifact). Also sweep the adjacent gap at 25/50/75/100ms to map where the trigger boundary actually is. Every case logs the received cursor value for diagnosis regardless of pass/fail. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/e2e/iOS/__tests__/caretAdjacentTap.ts | 150 ++++++++++++---------- 1 file changed, 82 insertions(+), 68 deletions(-) diff --git a/src/e2e/iOS/__tests__/caretAdjacentTap.ts b/src/e2e/iOS/__tests__/caretAdjacentTap.ts index 35282d578de..9fffd520bd6 100644 --- a/src/e2e/iOS/__tests__/caretAdjacentTap.ts +++ b/src/e2e/iOS/__tests__/caretAdjacentTap.ts @@ -11,13 +11,6 @@ import getNativeElementRect from '../helpers/getNativeElementRect' import paste from '../helpers/paste' import waitForEditable from '../helpers/waitForEditable' -// Sub-second inter-tap gaps (ms) that fall at or below WebKit's double-tap-to-zoom detection window -// (~300-350ms). Sequential `mobile: tap` calls cannot reach this window (each has a ~375ms WDA -// round-trip, a ~751ms floor for two taps), so the two taps are dispatched here as a SINGLE atomic -// performActions chain whose in-chain pause is the exact gap - the only way to probe the double-tap -// window at two distinct points. -const GAPS_MS = [0, 100, 200, 300] - // Milliseconds each tap is held down before release. Kept short so each tap reads as a crisp quick // tap, the kind WebKit's rapid-tap / double-tap detection responds to. const HOLD_MS = 20 @@ -35,77 +28,98 @@ interface ScreenPoint { y: number } -describe('Caret', () => { - GAPS_MS.forEach(gapMs => { - it(`Set caret on adjacent thought ${gapMs}ms after tap (#4173)`, async () => { - // Reproduces https://github.com/cybersemics/em/issues/4173: tapping an adjacent thought within - // ~1 second of a previous tap fails to move the cursor (the second tap's setCursor is not - // dispatched and focus is not triggered on the ContentEditable). A non-adjacent thought works. - const importText = ` +/** + * Seeds three sibling thoughts a/b/c, then taps thought `a` followed by `targetValue` in a single + * atomic performActions chain whose in-chain pause is exactly `gapMs`. Returns the value of the + * thought the cursor ends on (via getEditingText), so the caller can compare against the target. + * + * Both taps are dispatched together, from the NATIVE_APP context, with a finger-sized contact area so + * iOS processes them like physical taps. Dispatching them as one chain is the only way to reach the + * sub-100ms inter-tap gaps that two sequential `mobile: tap` calls (each ~375ms round-trip) cannot. + */ +const twoTap = async (targetValue: string, gapMs: number): Promise => { + const importText = ` - a - b - c` - await paste(importText) - await waitForEditable('c') + await paste(importText) + await waitForEditable('c') - // Stabilize layout so the touch coordinates resolve against a settled DOM. - await browser.execute(() => window.scrollTo(0, 0)) - await browser.pause(400) + // Stabilize layout so the touch coordinates resolve against a settled DOM. + await browser.execute(() => window.scrollTo(0, 0)) + await browser.pause(400) - const a = await waitForEditable('a') - const b = await waitForEditable('b') + const a = await waitForEditable('a') + const target = await waitForEditable(targetValue) - // Cache the Safari webview container's screen offset ONCE (native touches use absolute screen - // coordinates). The offset is a property of the browser chrome, not the page, so it is constant. - const offset = await getNativeElementRect('//XCUIElementTypeOther[@name="em"]') + // Cache the Safari webview container's screen offset ONCE (native touches use absolute screen + // coordinates). The offset is a property of the browser chrome, not the page, so it is constant. + const offset = await getNativeElementRect('//XCUIElementTypeOther[@name="em"]') - /** Absolute screen center of an editable, using the cached container offset. */ - const screenCenter = async (editable: Element): Promise => { - const rect = await browser.getElementRect(editable.elementId) - return { - x: Math.round(rect.x + offset.x + rect.width / 2), - y: Math.round(rect.y + offset.y + rect.height / 2), - } - } + /** Absolute screen center of an editable, using the cached container offset. */ + const screenCenter = async (editable: Element): Promise => { + const rect = await browser.getElementRect(editable.elementId) + return { + x: Math.round(rect.x + offset.x + rect.width / 2), + y: Math.round(rect.y + offset.y + rect.height / 2), + } + } - // Resolve both centers up front, while the keyboard is still closed. Because both taps fire in a - // single sub-second action chain (below), the keyboard has not yet opened when b is tapped, so - // b's pre-keyboard coordinate is the correct one at chain-execution time. - const aPoint = await screenCenter(a) - const bPoint = await screenCenter(b) + // Resolve both centers up front, while the keyboard is still closed. Because both taps fire in a + // single sub-second action chain, the keyboard has not yet opened when the target is tapped, so its + // pre-keyboard coordinate is the correct one at chain-execution time. + const aPoint = await screenCenter(a) + const targetPoint = await screenCenter(target) - // Dispatch BOTH taps in one atomic performActions chain from the NATIVE_APP context, with a - // finger-sized contact area so iOS processes them like physical taps. The in-chain pause is the - // exact inter-tap gap, letting us probe WebKit's double-tap window (<=300ms) that two sequential - // `mobile: tap` calls cannot reach. - const webviewContext = (await browser.getContext()) as string - await browser.switchContext('NATIVE_APP') - const t0 = Date.now() - await browser.performActions([ - { - type: 'pointer', - id: 'finger1', - parameters: { pointerType: 'touch' }, - actions: [ - { type: 'pointerMove', duration: 0, x: aPoint.x, y: aPoint.y, origin: 'viewport', ...CONTACT }, - { type: 'pointerDown', button: 0, ...CONTACT }, - { type: 'pause', duration: HOLD_MS }, - { type: 'pointerUp', button: 0 }, - { type: 'pause', duration: gapMs }, - { type: 'pointerMove', duration: 0, x: bPoint.x, y: bPoint.y, origin: 'viewport', ...CONTACT }, - { type: 'pointerDown', button: 0, ...CONTACT }, - { type: 'pause', duration: HOLD_MS }, - { type: 'pointerUp', button: 0 }, - ], - }, - ]) - const elapsed = Date.now() - t0 - await browser.switchContext(webviewContext) - console.info(`#4173 single-chain two-tap: chain took ${elapsed}ms (gap ${gapMs}ms, hold ${HOLD_MS}ms)`) + const webviewContext = (await browser.getContext()) as string + await browser.switchContext('NATIVE_APP') + const t0 = Date.now() + await browser.performActions([ + { + type: 'pointer', + id: 'finger1', + parameters: { pointerType: 'touch' }, + actions: [ + { type: 'pointerMove', duration: 0, x: aPoint.x, y: aPoint.y, origin: 'viewport', ...CONTACT }, + { type: 'pointerDown', button: 0, ...CONTACT }, + { type: 'pause', duration: HOLD_MS }, + { type: 'pointerUp', button: 0 }, + { type: 'pause', duration: gapMs }, + { type: 'pointerMove', duration: 0, x: targetPoint.x, y: targetPoint.y, origin: 'viewport', ...CONTACT }, + { type: 'pointerDown', button: 0, ...CONTACT }, + { type: 'pause', duration: HOLD_MS }, + { type: 'pointerUp', button: 0 }, + ], + }, + ]) + const elapsed = Date.now() - t0 + await browser.switchContext(webviewContext) - // Give the app a moment to dispatch setCursor and re-render, then assert the cursor moved to b. - await browser.pause(1000) - expect(await getEditingText()).toBe('b') + // Give the app a moment to dispatch setCursor and re-render before reading the cursor position. + await browser.pause(1000) + const received = await getEditingText() + console.info(`#4173 twoTap a->${targetValue} gap ${gapMs}ms: received "${received}" (chain ${elapsed}ms)`) + return received +} + +describe('Caret', () => { + // Map the trigger boundary for the ADJACENT case (a->b). #4173's symptom is the cursor staying on + // the first thought (a) instead of moving to the second (b). The gap is the exact in-chain pause + // between the two taps. A faithful repro of the user-reported "within a second" behavior would fail + // across a sub-second range; if only ~0ms fails, the failure is more likely a synthetic-input + // artifact (two ultra-fast touches coalesced) than the real rapid-tap focus bug. + const ADJACENT_GAPS_MS = [0, 25, 50, 75, 100] + ADJACENT_GAPS_MS.forEach(gapMs => { + it(`Set caret on adjacent thought ${gapMs}ms after tap (#4173)`, async () => { + expect(await twoTap('b', gapMs)).toBe('b') }) }) + + // Non-adjacent control: at the SAME 0ms timing, tapping a distant thought (a->c) must still move the + // cursor, per the issue ("A non-adjacent thought works"). This discriminates a genuine #4173 repro + // from an artifact: if a->b fails at 0ms but a->c passes at 0ms, the bug is adjacency-specific (real + // #4173). If a->c ALSO fails at 0ms, the second tap is simply being dropped (artifact, not #4173). + it('Set caret on non-adjacent thought 0ms after tap (#4173 control)', async () => { + expect(await twoTap('c', 0)).toBe('c') + }) }) From 925e76ada666a4de341bce1c5d27e19cb12921fe Mon Sep 17 00:00:00 2001 From: Ethan James Date: Tue, 7 Jul 2026 15:12:47 -0700 Subject: [PATCH 11/18] debug(editable): instrument tap->focus->setCursor flow for #4173 [temporary] Add [#4173]-tagged console.info logging with high-resolution performance.now() timestamps across the full tap handling path in Editable.tsx: - native touchend/click listeners (earliest per-tap signal) - handleTapBehavior (long-press bail, hidden/multicursor branch, or the common no-op case where the cursor is set via onFocus instead) - onFocus (suppressFocus return, long-press skip, -> setCursorOnThought) - setCursorOnThought (the unchanged-cursor guard return vs. the setCursor dispatch) Purpose: on a real iOS device where a human CAN reproduce #4173, inspect via Safari Web Inspector (filter "[#4173]") to see which handler fires (or fails to fire) on the second tap, whether the second setCursor is suppressed by the guard or never dispatched at all, and the true inter-tap interval. This determines the real trigger window and mechanism, which no synthetic-touch vector reproduced. Temporary investigation scaffolding - remove before merge. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/components/Editable.tsx | 40 ++++++++++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/src/components/Editable.tsx b/src/components/Editable.tsx index 4f5572c5a2f..a92ecd2a7f3 100644 --- a/src/components/Editable.tsx +++ b/src/components/Editable.tsx @@ -103,6 +103,16 @@ const applyOuterTag = (newValue: string, oldValue: string): string => { // this flag is used to ensure that the browser selection is not restored after the initial setCursorOnThought let cursorOffsetInitialized = false +/** + * TEMPORARY instrumentation for #4173 (tap adjacent thought within ~1s fails to move the caret). + * Logs the full tap -> focus -> setCursor flow with high-resolution timestamps so the sequence and + * inter-tap interval can be inspected via Safari Web Inspector while reproducing the bug by hand on a + * real iOS device. Each line is tagged [#4173] for easy filtering. Remove before merging. + */ +const log4173 = (msg: string): void => { + console.info(`[#4173] ${performance.now().toFixed(1)}ms ${msg}`) +} + /** * An editable thought with throttled editing. * Use rank instead of headRank(simplePath) as it will be different for context view. @@ -214,8 +224,16 @@ const Editable = ({ dispatch((dispatch, getState) => { const state = getState() + const alreadyCursor = equalPath(state.cursor, path) + log4173( + `setCursorOnThought value="${value}" isKeyboardOpen=${isKeyboardOpen} stateKeyboardOpen=${state.isKeyboardOpen} alreadyCursor=${alreadyCursor}`, + ) + // do not set cursor if it is unchanged and we are not entering when keyboard is open - if ((!isKeyboardOpen || state.isKeyboardOpen) && equalPath(state.cursor, path)) return + if ((!isKeyboardOpen || state.isKeyboardOpen) && equalPath(state.cursor, path)) { + log4173(`setCursorOnThought RETURN (guard: unchanged cursor) value="${value}"`) + return + } // set offset to null to allow the browser to set the position of the selection let offset = null @@ -231,6 +249,7 @@ const Editable = ({ // Prevent the cursor offset from being restored after the initial setCursorOnThought. cursorOffsetInitialized = true + log4173(`setCursorOnThought DISPATCH setCursor value="${value}"`) dispatch( setCursor({ cursorHistoryClear: true, @@ -609,6 +628,7 @@ const Editable = ({ */ const onFocus = useCallback( () => { + log4173(`onFocus value="${value}" suppressFocus=${suppressFocusStore.getState()}`) /** * On iOS, a long press between 415–650ms will trigger onFocus even when preventDefault is called in touchend, thus opening the virtual keyboard on top of the Command Center. There appears to be no way to prevent focus in this case. Therefore, we clear the selection and disable edit mode manually as soon as the focus triggers. * @@ -632,14 +652,20 @@ const Editable = ({ }) } - if (suppressFocusStore.getState()) return + if (suppressFocusStore.getState()) { + log4173(`onFocus RETURN (suppressFocus) value="${value}"`) + return + } // Update editingValueUntrimmedStore with the current value editingValueUntrimmedStore.update(value) dispatch((dispatch, getState) => { const { longPress } = getState() if (longPress === LongPressState.Inactive) { + log4173(`onFocus -> setCursorOnThought value="${value}"`) setCursorOnThought({ isKeyboardOpen: true }) + } else { + log4173(`onFocus SKIP setCursorOnThought (longPress=${longPress}) value="${value}"`) } }) }, @@ -653,6 +679,7 @@ const Editable = ({ */ const handleTapBehavior = useCallback( (e: MouseEvent | TouchEvent) => { + log4173(`handleTapBehavior type=${e.type} value="${value}" touching=${globals.touching}`) // When MultiGesture is below the gesture threshold it is possible that onClick and onTouchEnd // both trigger. Prevent handleTapBehavior from running a second time via touchend in that case. // https://github.com/cybersemics/em/issues/1268 @@ -665,6 +692,7 @@ const Editable = ({ // If long press is in progress, don't allow the editable to receive focus or iOS Safari will scroll it. if (state.longPress !== LongPressState.Inactive) { + log4173(`handleTapBehavior BAIL (longPress=${state.longPress}) value="${value}"`) e.preventDefault() return } @@ -680,17 +708,21 @@ const Editable = ({ e.preventDefault() if (!isVisible) { + log4173(`handleTapBehavior hidden -> clear/toggleDropdown value="${value}"`) selection.clear() // close all popups when clicking on a thought dispatch(toggleDropdown()) } else { + log4173(`handleTapBehavior -> setCursorOnThought value="${value}"`) setCursorOnThought() } + } else { + log4173(`handleTapBehavior no-op (cursor set via onFocus) value="${value}"`) } }) }, - [disabled, dispatch, editingOrOnCursor, isVisible, setCursorOnThought], + [disabled, dispatch, editingOrOnCursor, isVisible, setCursorOnThought, value], ) /** Registers native event listeners for tap behavior (click and touchend). */ @@ -700,6 +732,7 @@ const Editable = ({ /** Sets the cursor on the thought on click. Handles hidden elements, drags, and editing mode. */ const onClick = (e: MouseEvent) => { + log4173(`native click value="${editable.textContent ?? ''}"`) // If CMD/CTRL is pressed, don't focus the editable. const isMultiselectClick = isMac ? e.metaKey : e.ctrlKey if (isMultiselectClick) { @@ -712,6 +745,7 @@ const Editable = ({ /** Handles touchend for haptics and tap behavior. */ const onTouchEnd = (e: TouchEvent) => { + log4173(`native touchend value="${editable.textContent ?? ''}"`) haptics.light() handleTapBehavior(e) } From b5303113133faa38273da79df142f6a606cf19b2 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Tue, 7 Jul 2026 15:35:52 -0700 Subject: [PATCH 12/18] test(#4173): add handler-level regression test; remove instrumentation; skip synthetic 0ms artifact cases Adds a faithful, handler-level regression test for #4173 in src/components/__tests__/Editable.ts. It fires touchend on an adjacent thought while the keyboard is open (the state left by a prior tap) and asserts the cursor moves. This reproduces the bug's real mechanism: the touchend hits handleTapBehavior's 'no-op (cursor set via onFocus)' branch and, since iOS does not fire onFocus on a rapid re-tap, the cursor never moves. Marked it.fails to document the unfixed bug while keeping the unit suite green; remove .fails once #4173 is fixed. A pure synthetic-touch e2e cannot reproduce #4173 because WDA taps force focus onto the target, bypassing the real-finger rapid-tap focus suppression. Skips the two 0ms cases in the iOS synthetic spec (caretAdjacentTap.ts) that only reflected a synthetic-input coalescing artifact, keeping the passing 25-100ms cases and documentation of the boundary. Removes the temporary [#4173] console.info instrumentation from Editable.tsx now that the mechanism is pinned. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/components/Editable.tsx | 40 ++--------------- src/components/__tests__/Editable.ts | 52 +++++++++++++++++++++++ src/e2e/iOS/__tests__/caretAdjacentTap.ts | 41 ++++++++++++------ 3 files changed, 82 insertions(+), 51 deletions(-) diff --git a/src/components/Editable.tsx b/src/components/Editable.tsx index a92ecd2a7f3..4f5572c5a2f 100644 --- a/src/components/Editable.tsx +++ b/src/components/Editable.tsx @@ -103,16 +103,6 @@ const applyOuterTag = (newValue: string, oldValue: string): string => { // this flag is used to ensure that the browser selection is not restored after the initial setCursorOnThought let cursorOffsetInitialized = false -/** - * TEMPORARY instrumentation for #4173 (tap adjacent thought within ~1s fails to move the caret). - * Logs the full tap -> focus -> setCursor flow with high-resolution timestamps so the sequence and - * inter-tap interval can be inspected via Safari Web Inspector while reproducing the bug by hand on a - * real iOS device. Each line is tagged [#4173] for easy filtering. Remove before merging. - */ -const log4173 = (msg: string): void => { - console.info(`[#4173] ${performance.now().toFixed(1)}ms ${msg}`) -} - /** * An editable thought with throttled editing. * Use rank instead of headRank(simplePath) as it will be different for context view. @@ -224,16 +214,8 @@ const Editable = ({ dispatch((dispatch, getState) => { const state = getState() - const alreadyCursor = equalPath(state.cursor, path) - log4173( - `setCursorOnThought value="${value}" isKeyboardOpen=${isKeyboardOpen} stateKeyboardOpen=${state.isKeyboardOpen} alreadyCursor=${alreadyCursor}`, - ) - // do not set cursor if it is unchanged and we are not entering when keyboard is open - if ((!isKeyboardOpen || state.isKeyboardOpen) && equalPath(state.cursor, path)) { - log4173(`setCursorOnThought RETURN (guard: unchanged cursor) value="${value}"`) - return - } + if ((!isKeyboardOpen || state.isKeyboardOpen) && equalPath(state.cursor, path)) return // set offset to null to allow the browser to set the position of the selection let offset = null @@ -249,7 +231,6 @@ const Editable = ({ // Prevent the cursor offset from being restored after the initial setCursorOnThought. cursorOffsetInitialized = true - log4173(`setCursorOnThought DISPATCH setCursor value="${value}"`) dispatch( setCursor({ cursorHistoryClear: true, @@ -628,7 +609,6 @@ const Editable = ({ */ const onFocus = useCallback( () => { - log4173(`onFocus value="${value}" suppressFocus=${suppressFocusStore.getState()}`) /** * On iOS, a long press between 415–650ms will trigger onFocus even when preventDefault is called in touchend, thus opening the virtual keyboard on top of the Command Center. There appears to be no way to prevent focus in this case. Therefore, we clear the selection and disable edit mode manually as soon as the focus triggers. * @@ -652,20 +632,14 @@ const Editable = ({ }) } - if (suppressFocusStore.getState()) { - log4173(`onFocus RETURN (suppressFocus) value="${value}"`) - return - } + if (suppressFocusStore.getState()) return // Update editingValueUntrimmedStore with the current value editingValueUntrimmedStore.update(value) dispatch((dispatch, getState) => { const { longPress } = getState() if (longPress === LongPressState.Inactive) { - log4173(`onFocus -> setCursorOnThought value="${value}"`) setCursorOnThought({ isKeyboardOpen: true }) - } else { - log4173(`onFocus SKIP setCursorOnThought (longPress=${longPress}) value="${value}"`) } }) }, @@ -679,7 +653,6 @@ const Editable = ({ */ const handleTapBehavior = useCallback( (e: MouseEvent | TouchEvent) => { - log4173(`handleTapBehavior type=${e.type} value="${value}" touching=${globals.touching}`) // When MultiGesture is below the gesture threshold it is possible that onClick and onTouchEnd // both trigger. Prevent handleTapBehavior from running a second time via touchend in that case. // https://github.com/cybersemics/em/issues/1268 @@ -692,7 +665,6 @@ const Editable = ({ // If long press is in progress, don't allow the editable to receive focus or iOS Safari will scroll it. if (state.longPress !== LongPressState.Inactive) { - log4173(`handleTapBehavior BAIL (longPress=${state.longPress}) value="${value}"`) e.preventDefault() return } @@ -708,21 +680,17 @@ const Editable = ({ e.preventDefault() if (!isVisible) { - log4173(`handleTapBehavior hidden -> clear/toggleDropdown value="${value}"`) selection.clear() // close all popups when clicking on a thought dispatch(toggleDropdown()) } else { - log4173(`handleTapBehavior -> setCursorOnThought value="${value}"`) setCursorOnThought() } - } else { - log4173(`handleTapBehavior no-op (cursor set via onFocus) value="${value}"`) } }) }, - [disabled, dispatch, editingOrOnCursor, isVisible, setCursorOnThought, value], + [disabled, dispatch, editingOrOnCursor, isVisible, setCursorOnThought], ) /** Registers native event listeners for tap behavior (click and touchend). */ @@ -732,7 +700,6 @@ const Editable = ({ /** Sets the cursor on the thought on click. Handles hidden elements, drags, and editing mode. */ const onClick = (e: MouseEvent) => { - log4173(`native click value="${editable.textContent ?? ''}"`) // If CMD/CTRL is pressed, don't focus the editable. const isMultiselectClick = isMac ? e.metaKey : e.ctrlKey if (isMultiselectClick) { @@ -745,7 +712,6 @@ const Editable = ({ /** Handles touchend for haptics and tap behavior. */ const onTouchEnd = (e: TouchEvent) => { - log4173(`native touchend value="${editable.textContent ?? ''}"`) haptics.light() handleTapBehavior(e) } diff --git a/src/components/__tests__/Editable.ts b/src/components/__tests__/Editable.ts index 5760721b714..241968e8257 100644 --- a/src/components/__tests__/Editable.ts +++ b/src/components/__tests__/Editable.ts @@ -1,13 +1,18 @@ import { fireEvent } from '@testing-library/dom' import userEvent from '@testing-library/user-event' import { act } from 'react' +import { importTextActionCreator as importText } from '../../actions/importText' +import { keyboardOpenActionCreator as keyboardOpen } from '../../actions/keyboardOpen' import { HOME_TOKEN } from '../../constants' import * as selection from '../../device/selection' import exportContext from '../../selectors/exportContext' import store from '../../stores/app' import createTestApp, { cleanupTestApp } from '../../test-helpers/createTestApp' +import dispatch from '../../test-helpers/dispatch' import findThoughtByText from '../../test-helpers/queries/findThoughtByText' +import { setCursorFirstMatchActionCreator as setCursor } from '../../test-helpers/setCursorFirstMatch' import windowEvent from '../../test-helpers/windowEvent' +import headValue from '../../util/headValue' beforeEach(createTestApp) afterEach(cleanupTestApp) @@ -78,3 +83,50 @@ it('inserts emoji spacing immediately and allows Backspace at the emoji boundary expect(editable.textContent).toBe('🧠Hello') }) + +// Regression test for https://github.com/cybersemics/em/issues/4173 +// +// On iOS Safari, when the keyboard is already open (i.e. immediately after tapping one thought), +// a rapid tap on an adjacent thought fires `touchend` on the second thought but does NOT fire a +// `focus` event on it (and the synthesized `click` retargets to the first thought). Because +// `handleTapBehavior` defers cursor-setting to `onFocus` whenever `editingOrOnCursor` is true +// (and `editingOrOnCursor` is true whenever the keyboard is open), the second tap hits the +// "no-op (cursor set via onFocus)" branch and the cursor is never moved β€” it stays stuck on the +// first thought. This is the exact mechanism behind #4173. +// +// This test reproduces the bug at the handler level (a pure synthetic-touch e2e cannot: WebDriver +// taps force focus onto the target, which bypasses the real-finger rapid-tap focus suppression). +// It marks the first thought as the cursor with the keyboard open (the state left by the first +// tap), then fires `touchend` on the adjacent thought and asserts the cursor moves to it. +// +// It is currently expected to FAIL (hence `it.fails`), documenting the unfixed bug. When #4173 is +// fixed β€” by setting the cursor directly on `touchend` for a visible non-cursor thought instead of +// relying solely on `onFocus` β€” this test will start passing; remove `.fails` at that point so it +// becomes a live regression guard. +it.fails('tapping an adjacent thought while the keyboard is open moves the cursor (#4173)', async () => { + await dispatch([ + importText({ + text: ` + - a + - b + - c + `, + }), + ]) + await act(vi.runOnlyPendingTimersAsync) + + // Simulate the state left by the first tap: cursor on `a`, keyboard open. + await dispatch([setCursor(['a']), keyboardOpen({ value: true })]) + await act(vi.runOnlyPendingTimersAsync) + + // The second tap lands on the adjacent thought `b`. On iOS this fires `touchend` but not `focus`. + const editableB = (await findThoughtByText('b'))! + expect(editableB).toBeTruthy() + await act(async () => { + fireEvent.touchEnd(editableB) + }) + await act(vi.runOnlyPendingTimersAsync) + + const state = store.getState() + expect(state.cursor && headValue(state, state.cursor)).toBe('b') +}) diff --git a/src/e2e/iOS/__tests__/caretAdjacentTap.ts b/src/e2e/iOS/__tests__/caretAdjacentTap.ts index 9fffd520bd6..f56453f5946 100644 --- a/src/e2e/iOS/__tests__/caretAdjacentTap.ts +++ b/src/e2e/iOS/__tests__/caretAdjacentTap.ts @@ -1,9 +1,17 @@ /** - * IOS Safari caret regression test for #4173. + * IOS Safari caret spec for #4173 (documentation / synthetic-touch boundary). + * + * NOTE: A synthetic WebDriver touch CANNOT faithfully reproduce #4173. WDA / `performActions` / + * `mobile: tap` all FORCE focus onto the tapped element, which bypasses the real-finger rapid-tap + * FOCUS-suppression that causes the bug (on a real device the second rapid tap fires `touchend` but + * no `focus`, so the cursor is never moved). The faithful, handler-level regression test lives in + * src/components/__tests__/Editable.ts. This spec is retained to document the investigation: it + * proves native-gesture taps at genuine sub-second gaps DO move the cursor, and it isolates the one + * synthetic-input artifact (0ms coalescing) that superficially resembled β€” but was not β€” #4173. * * Isolated into its own spec file so it can be pinned to an iOS version whose Safari touch-adjustment - * / rapid-tap focus handling reproduces the bug (see wdio.browserstack.conf.ts). The rest of the iOS - * suite runs on the default device/version. + * / rapid-tap focus handling was under investigation (see wdio.browserstack.conf.ts). The rest of the + * iOS suite runs on the default device/version. */ import type { Element } from 'webdriverio' import getEditingText from '../helpers/getEditingText' @@ -103,23 +111,28 @@ const twoTap = async (targetValue: string, gapMs: number): Promise { - // Map the trigger boundary for the ADJACENT case (a->b). #4173's symptom is the cursor staying on - // the first thought (a) instead of moving to the second (b). The gap is the exact in-chain pause - // between the two taps. A faithful repro of the user-reported "within a second" behavior would fail - // across a sub-second range; if only ~0ms fails, the failure is more likely a synthetic-input - // artifact (two ultra-fast touches coalesced) than the real rapid-tap focus bug. - const ADJACENT_GAPS_MS = [0, 25, 50, 75, 100] + // Native-gesture taps at a genuine sub-second gap (>=25ms) DO move the cursor to the adjacent + // thought. These pass on BrowserStack, confirming that synthetic native taps at these intervals + // cannot reproduce #4173. The gap is the exact in-chain pause between the two taps. + const ADJACENT_GAPS_MS = [25, 50, 75, 100] ADJACENT_GAPS_MS.forEach(gapMs => { it(`Set caret on adjacent thought ${gapMs}ms after tap (#4173)`, async () => { expect(await twoTap('b', gapMs)).toBe('b') }) }) - // Non-adjacent control: at the SAME 0ms timing, tapping a distant thought (a->c) must still move the - // cursor, per the issue ("A non-adjacent thought works"). This discriminates a genuine #4173 repro - // from an artifact: if a->b fails at 0ms but a->c passes at 0ms, the bug is adjacency-specific (real - // #4173). If a->c ALSO fails at 0ms, the second tap is simply being dropped (artifact, not #4173). - it('Set caret on non-adjacent thought 0ms after tap (#4173 control)', async () => { + // SKIPPED β€” synthetic-input artifact, not a faithful #4173 repro (kept for documentation). + // At a literal 0ms in-chain gap, WDA/WebKit spatially COALESCES the two synthetic touches into a + // single averaged tap: a->b lands back on "a", and the non-adjacent control a->c lands on the + // geometric MIDPOINT "b" (not "c"). Genuine #4173 is a rapid-tap FOCUS-suppression bug that WDA + // taps bypass entirely (they force focus onto the target). The faithful, handler-level regression + // test is `#4173` in src/components/__tests__/Editable.ts. These two cases are skipped so CI stays + // green while still recording the exact artifact boundary. + it.skip('Set caret on adjacent thought 0ms after tap (#4173 β€” synthetic artifact, see Editable.ts)', async () => { + expect(await twoTap('b', 0)).toBe('b') + }) + + it.skip('Set caret on non-adjacent thought 0ms after tap (#4173 control β€” synthetic artifact)', async () => { expect(await twoTap('c', 0)).toBe('c') }) }) From e7d7f687879ace58a8b47d842f1c793065cd0d01 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Tue, 7 Jul 2026 15:42:50 -0700 Subject: [PATCH 13/18] test(#4173): use plain it() characterization assertion instead of it.fails The project's global `it` is typed via jest-compatible types that do not declare vitest's `.fails`, so `lint:tsc` (and every build-dependent CI job: Lint, Puppeteer, BrowserStack, Vercel) failed with TS2339. Rewrite the #4173 handler-level test as a running, green characterization test that asserts the current buggy outcome (cursor stays on 'a') with an explicit instruction to flip the expected value to 'b' when #4173 is fixed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/components/__tests__/Editable.ts | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/components/__tests__/Editable.ts b/src/components/__tests__/Editable.ts index 241968e8257..4afcf45c262 100644 --- a/src/components/__tests__/Editable.ts +++ b/src/components/__tests__/Editable.ts @@ -84,7 +84,7 @@ it('inserts emoji spacing immediately and allows Backspace at the emoji boundary expect(editable.textContent).toBe('🧠Hello') }) -// Regression test for https://github.com/cybersemics/em/issues/4173 +// Regression / characterization test for https://github.com/cybersemics/em/issues/4173 // // On iOS Safari, when the keyboard is already open (i.e. immediately after tapping one thought), // a rapid tap on an adjacent thought fires `touchend` on the second thought but does NOT fire a @@ -94,16 +94,18 @@ it('inserts emoji spacing immediately and allows Backspace at the emoji boundary // "no-op (cursor set via onFocus)" branch and the cursor is never moved β€” it stays stuck on the // first thought. This is the exact mechanism behind #4173. // -// This test reproduces the bug at the handler level (a pure synthetic-touch e2e cannot: WebDriver -// taps force focus onto the target, which bypasses the real-finger rapid-tap focus suppression). -// It marks the first thought as the cursor with the keyboard open (the state left by the first -// tap), then fires `touchend` on the adjacent thought and asserts the cursor moves to it. +// This reproduces the bug at the handler level. A pure synthetic-touch e2e cannot: WebDriver taps +// force focus onto the target, which bypasses the real-finger rapid-tap focus suppression. // -// It is currently expected to FAIL (hence `it.fails`), documenting the unfixed bug. When #4173 is -// fixed β€” by setting the cursor directly on `touchend` for a visible non-cursor thought instead of -// relying solely on `onFocus` β€” this test will start passing; remove `.fails` at that point so it -// becomes a live regression guard. -it.fails('tapping an adjacent thought while the keyboard is open moves the cursor (#4173)', async () => { +// The test marks the first thought as the cursor with the keyboard open (the state left by the +// first tap), then fires `touchend` on the adjacent thought. It asserts the CURRENT (buggy) +// outcome: the cursor stays on 'a'. The correct, post-fix behavior is that the cursor moves to 'b'. +// +// WHEN #4173 IS FIXED β€” by setting the cursor directly on `touchend` for a visible non-cursor +// thought instead of relying solely on `onFocus` β€” this test will start failing. At that point, +// change the expected value below from 'a' to 'b' (and update this comment) so it becomes a live +// regression guard for the fixed behavior. +it('tapping an adjacent thought while the keyboard is open does not move the cursor (#4173)', async () => { await dispatch([ importText({ text: ` @@ -128,5 +130,7 @@ it.fails('tapping an adjacent thought while the keyboard is open moves the curso await act(vi.runOnlyPendingTimersAsync) const state = store.getState() - expect(state.cursor && headValue(state, state.cursor)).toBe('b') + const cursorValue = state.cursor ? headValue(state, state.cursor) : undefined + // BUG #4173: this should be 'b' once fixed; today the cursor stays stuck on 'a'. + expect(cursorValue).toBe('a') }) From 78bcd7cfcae19139dd9489dc95fb44a9aea783e3 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Tue, 7 Jul 2026 16:03:43 -0700 Subject: [PATCH 14/18] test(#4173): add positive control to make the repro non-tautological The prior single assertion (touchend while keyboard open does not move the cursor) only documented the branch structure: deferring to onFocus is by design, so on its own it did not demonstrate a bug, and it could pass green even if the touchend handler were detached. Add a positive control that fires the SAME touchend on the adjacent thought with the keyboard CLOSED and asserts the cursor DOES move to it. The two cases now differ only in keyboard state, isolating #4173: touchend is proven to drive the cursor-setting chain (control -> 'b'), and the keyboard-open deferral is what suppresses it (bug -> stays 'a'). Extracts a shared tapAdjacentThoughtOnTouchEnd helper. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/components/__tests__/Editable.ts | 73 +++++++++++++++++++--------- 1 file changed, 49 insertions(+), 24 deletions(-) diff --git a/src/components/__tests__/Editable.ts b/src/components/__tests__/Editable.ts index 4afcf45c262..196c7a9fe55 100644 --- a/src/components/__tests__/Editable.ts +++ b/src/components/__tests__/Editable.ts @@ -84,28 +84,34 @@ it('inserts emoji spacing immediately and allows Backspace at the emoji boundary expect(editable.textContent).toBe('🧠Hello') }) -// Regression / characterization test for https://github.com/cybersemics/em/issues/4173 +// Tests for https://github.com/cybersemics/em/issues/4173. // -// On iOS Safari, when the keyboard is already open (i.e. immediately after tapping one thought), -// a rapid tap on an adjacent thought fires `touchend` on the second thought but does NOT fire a -// `focus` event on it (and the synthesized `click` retargets to the first thought). Because -// `handleTapBehavior` defers cursor-setting to `onFocus` whenever `editingOrOnCursor` is true -// (and `editingOrOnCursor` is true whenever the keyboard is open), the second tap hits the -// "no-op (cursor set via onFocus)" branch and the cursor is never moved β€” it stays stuck on the -// first thought. This is the exact mechanism behind #4173. +// #4173: on iOS Safari, tapping an adjacent thought immediately after tapping another one fails to +// move the caret β€” it stays stuck on the first thought. // -// This reproduces the bug at the handler level. A pure synthetic-touch e2e cannot: WebDriver taps -// force focus onto the target, which bypasses the real-finger rapid-tap focus suppression. +// Mechanism (confirmed by on-device logging). `handleTapBehavior` fires on `touchend`. For a visible +// thought it either sets the cursor directly, OR β€” when `editingOrOnCursor` is true β€” takes a +// "no-op (cursor set via onFocus)" branch that defers all cursor-setting to the thought's `onFocus` +// handler. `editingOrOnCursor` is true whenever the keyboard is open. After the first tap the +// keyboard is open, so a second, rapid tap on an adjacent thought hits that no-op branch. On a real +// device the rapid re-tap fires `touchend` on the second thought but NOT `focus` (the synthesized +// click retargets to the first thought), so `onFocus` never runs and nothing moves the cursor. // -// The test marks the first thought as the cursor with the keyboard open (the state left by the -// first tap), then fires `touchend` on the adjacent thought. It asserts the CURRENT (buggy) -// outcome: the cursor stays on 'a'. The correct, post-fix behavior is that the cursor moves to 'b'. -// -// WHEN #4173 IS FIXED β€” by setting the cursor directly on `touchend` for a visible non-cursor -// thought instead of relying solely on `onFocus` β€” this test will start failing. At that point, -// change the expected value below from 'a' to 'b' (and update this comment) so it becomes a live -// regression guard for the fixed behavior. -it('tapping an adjacent thought while the keyboard is open does not move the cursor (#4173)', async () => { +// This is reproduced at the handler level (a pure synthetic-touch e2e cannot: WebDriver taps force +// focus onto the target, bypassing the real-finger rapid-tap focus suppression). The two tests below +// differ ONLY in keyboard state, which isolates the bug: +// - keyboard CLOSED (control): `touchend` moves the cursor β€” proving `touchend` is wired and is +// supposed to drive the cursor-setting chain. +// - keyboard OPEN (#4173): the same `touchend` does NOT move the cursor, because the handler defers +// to an `onFocus` that never fires. + +/** + * Seeds three sibling thoughts a/b/c, puts the cursor on `a` with the keyboard open or closed (the + * state left by a first tap on `a`), then fires `touchend` on the adjacent thought `b` β€” the DOM + * events a rapid iOS re-tap delivers, notably WITHOUT a `focus` event. Returns the value of the + * thought the cursor ends on. + */ +const tapAdjacentThoughtOnTouchEnd = async (isKeyboardOpen: boolean): Promise => { await dispatch([ importText({ text: ` @@ -117,8 +123,8 @@ it('tapping an adjacent thought while the keyboard is open does not move the cur ]) await act(vi.runOnlyPendingTimersAsync) - // Simulate the state left by the first tap: cursor on `a`, keyboard open. - await dispatch([setCursor(['a']), keyboardOpen({ value: true })]) + // Set up the state left by the first tap on `a`: cursor on `a`, keyboard open or closed. + await dispatch([setCursor(['a']), keyboardOpen({ value: isKeyboardOpen })]) await act(vi.runOnlyPendingTimersAsync) // The second tap lands on the adjacent thought `b`. On iOS this fires `touchend` but not `focus`. @@ -130,7 +136,26 @@ it('tapping an adjacent thought while the keyboard is open does not move the cur await act(vi.runOnlyPendingTimersAsync) const state = store.getState() - const cursorValue = state.cursor ? headValue(state, state.cursor) : undefined - // BUG #4173: this should be 'b' once fixed; today the cursor stays stuck on 'a'. - expect(cursorValue).toBe('a') + return state.cursor ? headValue(state, state.cursor) : undefined +} + +describe('#4173: tapping an adjacent thought', () => { + // Control: with the keyboard closed, `handleTapBehavior` sets the cursor directly on `touchend`. + // This proves the touchend handler is wired and IS meant to drive the cursor-setting chain, so the + // bug test below cannot pass merely because the handler is detached or the tap is a no-op. + it('moves the cursor on touchend while the keyboard is closed (control)', async () => { + expect(await tapAdjacentThoughtOnTouchEnd(false)).toBe('b') + }) + + // #4173: with the keyboard open (the state after a first tap), the SAME `touchend` on the adjacent + // thought does NOT move the cursor β€” the handler defers to an `onFocus` that never fires on a rapid + // iOS re-tap. Asserts the CURRENT (buggy) outcome: the cursor stays on 'a'. + // + // WHEN #4173 IS FIXED β€” by having `handleTapBehavior` set the cursor directly on `touchend` for a + // visible non-cursor thought instead of relying solely on `onFocus` β€” this test will start failing. + // At that point change the expected value from 'a' to 'b' (matching the control) so it becomes a + // live regression guard for the fixed behavior. + it('does NOT move the cursor on touchend while the keyboard is open (#4173)', async () => { + expect(await tapAdjacentThoughtOnTouchEnd(true)).toBe('a') + }) }) From 7634abcb72a153b9d04c7b45abcc933e43fffca6 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Wed, 8 Jul 2026 11:32:37 -0700 Subject: [PATCH 15/18] test(#4173): add non-adjacent tap control (keyboard open) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a second control proving the keyboard-open state is not itself the cause: a NON-ADJACENT tap (a->c) with the keyboard open still moves the cursor, matching the issue report that a non-adjacent thought works. Key finding: at the handler level the adjacency distinction is invisible β€” handleTapBehavior defers to onFocus for ANY thought while the keyboard is open. The real difference is whether iOS delivers a focus event on the second tap. iOS suppresses focus on a rapid ADJACENT re-tap (the bug) but delivers it on a non-adjacent tap (works). The tests now model this directly: touchend always fires; focus fires only when deliversFocus=true. Generalizes the helper to tapThoughtOnTouchEnd({ target, isKeyboardOpen, deliversFocus }). Three cases: - keyboard closed, touchend on b, no focus -> moves to b (control 1) - keyboard open, touchend+focus on c -> moves to c (control 2) - keyboard open, touchend on b, no focus -> stays on a (#4173) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/components/__tests__/Editable.ts | 89 ++++++++++++++++++---------- 1 file changed, 58 insertions(+), 31 deletions(-) diff --git a/src/components/__tests__/Editable.ts b/src/components/__tests__/Editable.ts index 196c7a9fe55..f09e0d374db 100644 --- a/src/components/__tests__/Editable.ts +++ b/src/components/__tests__/Editable.ts @@ -86,32 +86,42 @@ it('inserts emoji spacing immediately and allows Backspace at the emoji boundary // Tests for https://github.com/cybersemics/em/issues/4173. // -// #4173: on iOS Safari, tapping an adjacent thought immediately after tapping another one fails to -// move the caret β€” it stays stuck on the first thought. +// #4173: on iOS Safari, tapping an ADJACENT thought immediately after tapping another one fails to +// move the caret β€” it stays stuck on the first thought. Tapping a NON-ADJACENT thought works. // // Mechanism (confirmed by on-device logging). `handleTapBehavior` fires on `touchend`. For a visible // thought it either sets the cursor directly, OR β€” when `editingOrOnCursor` is true β€” takes a // "no-op (cursor set via onFocus)" branch that defers all cursor-setting to the thought's `onFocus` // handler. `editingOrOnCursor` is true whenever the keyboard is open. After the first tap the -// keyboard is open, so a second, rapid tap on an adjacent thought hits that no-op branch. On a real -// device the rapid re-tap fires `touchend` on the second thought but NOT `focus` (the synthesized -// click retargets to the first thought), so `onFocus` never runs and nothing moves the cursor. +// keyboard is open, so the handler defers to `onFocus` for the second tap regardless of which thought +// is tapped. The adjacency difference is NOT in the handler β€” it is in whether iOS delivers a `focus` +// event on the second tap: +// - Adjacent re-tap: iOS fires `touchend` on the second thought but NOT `focus` (the synthesized +// click retargets to the first thought). `onFocus` never runs, so the deferral is never honored +// and the cursor stays put. THIS IS THE BUG. +// - Non-adjacent tap: iOS DOES fire `focus`, so the deferred `onFocus` runs and the cursor moves. +// This is why a non-adjacent tap works even with the keyboard open. // // This is reproduced at the handler level (a pure synthetic-touch e2e cannot: WebDriver taps force -// focus onto the target, bypassing the real-finger rapid-tap focus suppression). The two tests below -// differ ONLY in keyboard state, which isolates the bug: -// - keyboard CLOSED (control): `touchend` moves the cursor β€” proving `touchend` is wired and is -// supposed to drive the cursor-setting chain. -// - keyboard OPEN (#4173): the same `touchend` does NOT move the cursor, because the handler defers -// to an `onFocus` that never fires. +// focus onto the target, bypassing the real-finger rapid-tap focus suppression). The tests below +// faithfully model each case by firing exactly the DOM events iOS delivers in that scenario. /** - * Seeds three sibling thoughts a/b/c, puts the cursor on `a` with the keyboard open or closed (the - * state left by a first tap on `a`), then fires `touchend` on the adjacent thought `b` β€” the DOM - * events a rapid iOS re-tap delivers, notably WITHOUT a `focus` event. Returns the value of the + * Seeds three sibling thoughts a/b/c, puts the cursor on `a` (the state left by a first tap on `a`) + * with the keyboard open or closed, then simulates a tap on `target` by firing the exact DOM events + * iOS delivers: always `touchend`, plus `focus` only when `deliversFocus` is true (iOS suppresses + * `focus` on a rapid ADJACENT re-tap but delivers it on a non-adjacent tap). Returns the value of the * thought the cursor ends on. */ -const tapAdjacentThoughtOnTouchEnd = async (isKeyboardOpen: boolean): Promise => { +const tapThoughtOnTouchEnd = async ({ + target, + isKeyboardOpen, + deliversFocus, +}: { + target: string + isKeyboardOpen: boolean + deliversFocus: boolean +}): Promise => { await dispatch([ importText({ text: ` @@ -127,35 +137,52 @@ const tapAdjacentThoughtOnTouchEnd = async (isKeyboardOpen: boolean): Promise { - fireEvent.touchEnd(editableB) + fireEvent.touchEnd(editable) }) + // focus fires only when iOS delivers it (non-adjacent tap); it is suppressed on a rapid adjacent re-tap. + if (deliversFocus) { + await act(async () => { + editable.focus() + fireEvent.focus(editable) + }) + } await act(vi.runOnlyPendingTimersAsync) const state = store.getState() return state.cursor ? headValue(state, state.cursor) : undefined } -describe('#4173: tapping an adjacent thought', () => { - // Control: with the keyboard closed, `handleTapBehavior` sets the cursor directly on `touchend`. - // This proves the touchend handler is wired and IS meant to drive the cursor-setting chain, so the - // bug test below cannot pass merely because the handler is detached or the tap is a no-op. +describe('#4173: tapping a thought after another', () => { + // Control 1: with the keyboard CLOSED, `handleTapBehavior` sets the cursor directly on `touchend` + // (no deferral to onFocus). This proves the touchend handler is wired and IS meant to drive the + // cursor-setting chain, so the bug test below cannot pass merely because the handler is detached. it('moves the cursor on touchend while the keyboard is closed (control)', async () => { - expect(await tapAdjacentThoughtOnTouchEnd(false)).toBe('b') + expect(await tapThoughtOnTouchEnd({ target: 'b', isKeyboardOpen: false, deliversFocus: false })).toBe('b') + }) + + // Control 2: NON-ADJACENT tap with the keyboard OPEN. iOS delivers `focus`, so the deferred + // `onFocus` runs and the cursor moves. This proves the keyboard-open state is NOT itself the + // problem β€” a tap works fine with the keyboard open as long as `focus` is delivered. It isolates the + // bug to the missing `focus` on the adjacent re-tap, matching the issue report that a non-adjacent + // thought works. + it('moves the cursor on a non-adjacent tap while the keyboard is open (control)', async () => { + expect(await tapThoughtOnTouchEnd({ target: 'c', isKeyboardOpen: true, deliversFocus: true })).toBe('c') }) - // #4173: with the keyboard open (the state after a first tap), the SAME `touchend` on the adjacent - // thought does NOT move the cursor β€” the handler defers to an `onFocus` that never fires on a rapid - // iOS re-tap. Asserts the CURRENT (buggy) outcome: the cursor stays on 'a'. + // #4173: ADJACENT re-tap with the keyboard OPEN. iOS fires `touchend` but suppresses `focus`, so the + // handler's deferral to `onFocus` is never honored and the cursor does not move. Asserts the CURRENT + // (buggy) outcome: the cursor stays on 'a'. // // WHEN #4173 IS FIXED β€” by having `handleTapBehavior` set the cursor directly on `touchend` for a // visible non-cursor thought instead of relying solely on `onFocus` β€” this test will start failing. - // At that point change the expected value from 'a' to 'b' (matching the control) so it becomes a - // live regression guard for the fixed behavior. - it('does NOT move the cursor on touchend while the keyboard is open (#4173)', async () => { - expect(await tapAdjacentThoughtOnTouchEnd(true)).toBe('a') + // At that point change the expected value from 'a' to 'b' (matching control 1) so it becomes a live + // regression guard for the fixed behavior. + it('does NOT move the cursor on an adjacent re-tap while the keyboard is open (#4173)', async () => { + expect(await tapThoughtOnTouchEnd({ target: 'b', isKeyboardOpen: true, deliversFocus: false })).toBe('a') }) }) From d2928be914464ffd152527e9fa33efa42bb64cd7 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Wed, 8 Jul 2026 11:59:11 -0700 Subject: [PATCH 16/18] test(#4173): remove circular synthetic-focus control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The non-adjacent control fired a synthetic focus event, which just invokes the onFocus handler directly. The presence/absence of that focus event IS the bug, so manufacturing it proves nothing about the tap behavior β€” it only proves that dispatching focus runs onFocus. At the handler level the adjacency distinction does not exist: with the keyboard open, touchend alone on ANY thought (adjacent or not) stays on 'a'. The reason a non-adjacent tap works on a real device is that iOS delivers a focus event, which jsdom cannot model without circularly firing it ourselves. Document this and drop the control. Keeps the keyboard-closed control (touchend genuinely moves the cursor, no synthetic focus) so the bug test remains non-tautological: the two tests differ only in keyboard state. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/components/__tests__/Editable.ts | 72 +++++++++++----------------- 1 file changed, 28 insertions(+), 44 deletions(-) diff --git a/src/components/__tests__/Editable.ts b/src/components/__tests__/Editable.ts index f09e0d374db..4da3ddbd3b1 100644 --- a/src/components/__tests__/Editable.ts +++ b/src/components/__tests__/Editable.ts @@ -93,34 +93,33 @@ it('inserts emoji spacing immediately and allows Backspace at the emoji boundary // thought it either sets the cursor directly, OR β€” when `editingOrOnCursor` is true β€” takes a // "no-op (cursor set via onFocus)" branch that defers all cursor-setting to the thought's `onFocus` // handler. `editingOrOnCursor` is true whenever the keyboard is open. After the first tap the -// keyboard is open, so the handler defers to `onFocus` for the second tap regardless of which thought -// is tapped. The adjacency difference is NOT in the handler β€” it is in whether iOS delivers a `focus` -// event on the second tap: -// - Adjacent re-tap: iOS fires `touchend` on the second thought but NOT `focus` (the synthesized -// click retargets to the first thought). `onFocus` never runs, so the deferral is never honored -// and the cursor stays put. THIS IS THE BUG. -// - Non-adjacent tap: iOS DOES fire `focus`, so the deferred `onFocus` runs and the cursor moves. -// This is why a non-adjacent tap works even with the keyboard open. +// keyboard is open, so `touchend` on the second thought defers to `onFocus` and does nothing on its +// own. On a real device the rapid ADJACENT re-tap fires `touchend` but iOS suppresses `focus` (the +// synthesized click retargets to the first thought), so `onFocus` never runs and the cursor stays put. // -// This is reproduced at the handler level (a pure synthetic-touch e2e cannot: WebDriver taps force -// focus onto the target, bypassing the real-finger rapid-tap focus suppression). The tests below -// faithfully model each case by firing exactly the DOM events iOS delivers in that scenario. +// The tests below reproduce this at the handler level by firing the DOM events iOS delivers β€” +// crucially, `touchend` WITHOUT a `focus` event. A pure synthetic-touch e2e cannot reproduce it: +// WebDriver taps force focus onto the target, bypassing the real-finger rapid-tap focus suppression. +// +// Note on the non-adjacent case: it is NOT reproducible at the handler level. The only reason a +// non-adjacent tap works is that iOS delivers a `focus` event, and at this level the handler cannot +// distinguish adjacency at all β€” `touchend` alone on ANY thought stays on 'a' while the keyboard is +// open. A "control" that fired a synthetic `focus` would just be invoking `onFocus` directly (the +// very event whose absence IS the bug), so it would prove nothing about the tap behavior. The +// adjacency difference lives in WebKit's focus delivery, which jsdom does not model. /** * Seeds three sibling thoughts a/b/c, puts the cursor on `a` (the state left by a first tap on `a`) - * with the keyboard open or closed, then simulates a tap on `target` by firing the exact DOM events - * iOS delivers: always `touchend`, plus `focus` only when `deliversFocus` is true (iOS suppresses - * `focus` on a rapid ADJACENT re-tap but delivers it on a non-adjacent tap). Returns the value of the - * thought the cursor ends on. + * with the keyboard open or closed, then fires `touchend` on `target` β€” the DOM event iOS delivers on + * a tap, notably WITHOUT a `focus` event (which iOS suppresses on a rapid adjacent re-tap). Returns + * the value of the thought the cursor ends on. */ const tapThoughtOnTouchEnd = async ({ target, isKeyboardOpen, - deliversFocus, }: { target: string isKeyboardOpen: boolean - deliversFocus: boolean }): Promise => { await dispatch([ importText({ @@ -140,17 +139,10 @@ const tapThoughtOnTouchEnd = async ({ const editable = (await findThoughtByText(target))! expect(editable).toBeTruthy() - // touchend always fires on the tapped thought. + // Fire touchend only β€” iOS suppresses the focus event on a rapid adjacent re-tap. await act(async () => { fireEvent.touchEnd(editable) }) - // focus fires only when iOS delivers it (non-adjacent tap); it is suppressed on a rapid adjacent re-tap. - if (deliversFocus) { - await act(async () => { - editable.focus() - fireEvent.focus(editable) - }) - } await act(vi.runOnlyPendingTimersAsync) const state = store.getState() @@ -158,31 +150,23 @@ const tapThoughtOnTouchEnd = async ({ } describe('#4173: tapping a thought after another', () => { - // Control 1: with the keyboard CLOSED, `handleTapBehavior` sets the cursor directly on `touchend` + // Control: with the keyboard CLOSED, `handleTapBehavior` sets the cursor directly on `touchend` // (no deferral to onFocus). This proves the touchend handler is wired and IS meant to drive the - // cursor-setting chain, so the bug test below cannot pass merely because the handler is detached. + // cursor-setting chain, so the bug test below cannot pass merely because the handler is detached or + // touchend is a no-op β€” the ONLY difference between the two tests is keyboard state. it('moves the cursor on touchend while the keyboard is closed (control)', async () => { - expect(await tapThoughtOnTouchEnd({ target: 'b', isKeyboardOpen: false, deliversFocus: false })).toBe('b') - }) - - // Control 2: NON-ADJACENT tap with the keyboard OPEN. iOS delivers `focus`, so the deferred - // `onFocus` runs and the cursor moves. This proves the keyboard-open state is NOT itself the - // problem β€” a tap works fine with the keyboard open as long as `focus` is delivered. It isolates the - // bug to the missing `focus` on the adjacent re-tap, matching the issue report that a non-adjacent - // thought works. - it('moves the cursor on a non-adjacent tap while the keyboard is open (control)', async () => { - expect(await tapThoughtOnTouchEnd({ target: 'c', isKeyboardOpen: true, deliversFocus: true })).toBe('c') + expect(await tapThoughtOnTouchEnd({ target: 'b', isKeyboardOpen: false })).toBe('b') }) - // #4173: ADJACENT re-tap with the keyboard OPEN. iOS fires `touchend` but suppresses `focus`, so the - // handler's deferral to `onFocus` is never honored and the cursor does not move. Asserts the CURRENT - // (buggy) outcome: the cursor stays on 'a'. + // #4173: with the keyboard OPEN (the state after a first tap), the SAME `touchend` β€” without a focus + // event, as on a rapid adjacent re-tap β€” does NOT move the cursor, because the handler defers to an + // `onFocus` that never fires. Asserts the CURRENT (buggy) outcome: the cursor stays on 'a'. // // WHEN #4173 IS FIXED β€” by having `handleTapBehavior` set the cursor directly on `touchend` for a // visible non-cursor thought instead of relying solely on `onFocus` β€” this test will start failing. - // At that point change the expected value from 'a' to 'b' (matching control 1) so it becomes a live - // regression guard for the fixed behavior. - it('does NOT move the cursor on an adjacent re-tap while the keyboard is open (#4173)', async () => { - expect(await tapThoughtOnTouchEnd({ target: 'b', isKeyboardOpen: true, deliversFocus: false })).toBe('a') + // At that point change the expected value from 'a' to 'b' (matching the control) so it becomes a + // live regression guard for the fixed behavior. + it('does NOT move the cursor on touchend (no focus) while the keyboard is open (#4173)', async () => { + expect(await tapThoughtOnTouchEnd({ target: 'b', isKeyboardOpen: true })).toBe('a') }) }) From 4bbb0474af5ab6ce1fea65df676f05c7c23fcddd Mon Sep 17 00:00:00 2001 From: Ethan James Date: Thu, 9 Jul 2026 12:35:45 -0700 Subject: [PATCH 17/18] test(ios): verify each synthetic tap lands on the correct thought (#4173) Rework the caretAdjacentTap spec so a two-tap outcome is trustworthy: - Add per-tap landing verification at two levels: a geometry check via document.elementFromPoint at each tap's client center (asserts the coordinate resolves to the intended thought), and a native-touch calibration that single-taps each of a/b/c in isolation and asserts the cursor lands there (proves the screen coordinates hit through the real touch path). - Restructure the two-tap repro into adjacent (a->b) and non-adjacent control (a->c) sweeps that hard-assert verified landings first, so the a->c control now directly catches touch-layer coalescing (verified-correct geometry but cursor landing on the midpoint b). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/e2e/iOS/__tests__/caretAdjacentTap.ts | 236 +++++++++++++++------- 1 file changed, 161 insertions(+), 75 deletions(-) diff --git a/src/e2e/iOS/__tests__/caretAdjacentTap.ts b/src/e2e/iOS/__tests__/caretAdjacentTap.ts index f56453f5946..0849c3b95d9 100644 --- a/src/e2e/iOS/__tests__/caretAdjacentTap.ts +++ b/src/e2e/iOS/__tests__/caretAdjacentTap.ts @@ -1,17 +1,29 @@ /** - * IOS Safari caret spec for #4173 (documentation / synthetic-touch boundary). + * IOS Safari caret spec for #4173 β€” synthetic-touch repro attempt WITH per-tap landing verification. * - * NOTE: A synthetic WebDriver touch CANNOT faithfully reproduce #4173. WDA / `performActions` / - * `mobile: tap` all FORCE focus onto the tapped element, which bypasses the real-finger rapid-tap - * FOCUS-suppression that causes the bug (on a real device the second rapid tap fires `touchend` but - * no `focus`, so the cursor is never moved). The faithful, handler-level regression test lives in - * src/components/__tests__/Editable.ts. This spec is retained to document the investigation: it - * proves native-gesture taps at genuine sub-second gaps DO move the cursor, and it isolates the one - * synthetic-input artifact (0ms coalescing) that superficially resembled β€” but was not β€” #4173. + * Background: a synthetic WebDriver touch has never faithfully reproduced #4173, because WDA / + * `performActions` / `mobile: tap` force focus onto the tapped element, bypassing the real-finger + * rapid-tap FOCUS-suppression that causes the bug (the faithful handler-level regression test lives in + * src/components/__tests__/Editable.ts). Every prior synthetic attempt was also undermined by a + * second, separate problem: we could never be sure the two taps actually LANDED on the intended + * thoughts. A "cursor moved to b" result could hide a mistargeted tap, and a "cursor stuck on a" could + * be a dropped/missed second tap rather than the bug. At a 0ms in-chain gap the two touches were even + * spatially COALESCED into a single averaged tap (a->c landed on the geometric midpoint b). * - * Isolated into its own spec file so it can be pinned to an iOS version whose Safari touch-adjustment - * / rapid-tap focus handling was under investigation (see wdio.browserstack.conf.ts). The rest of the - * iOS suite runs on the default device/version. + * This spec fixes the blind spot: every tap point is verified to land on the correct element before it + * is used, at two levels. (1) Geometry: `document.elementFromPoint` at the tap's client center must + * resolve to the intended thought (deterministic, perturbs no state). (2) Native touch: a single real + * native tap on each thought in isolation must move the cursor to it, proving the SCREEN coordinates + * land correctly through the actual touch path. Only then are the two-tap repro attempts run, so their + * outcomes are trustworthy. + * + * Coordinate spaces (critical): in the webview context `browser.getElementRect` and + * `document.elementFromPoint` both use CLIENT (viewport) CSS coordinates. Native `performActions` + * uses absolute SCREEN coordinates = client + the Safari webview container offset. The offset is a + * property of the browser chrome, not the page, so it is cached once via a single NATIVE_APP switch. + * + * Isolated into its own spec file so it can be pinned to a specific iOS version (see + * wdio.browserstack.conf.ts). The rest of the iOS suite runs on the default device/version. */ import type { Element } from 'webdriverio' import getEditingText from '../helpers/getEditingText' @@ -30,55 +42,90 @@ const HOLD_MS = 20 // "This device does not support force press interactions". const CONTACT = { width: 40, height: 40 } -/** Absolute device-screen coordinate for a native touch. */ -interface ScreenPoint { - x: number - y: number +/** A resolved tap target: the client (viewport) center for geometry checks and the absolute screen + * center for native performActions, plus the value we expect a tap there to hit. */ +interface TapTarget { + value: string + client: { x: number; y: number } + screen: { x: number; y: number } } -/** - * Seeds three sibling thoughts a/b/c, then taps thought `a` followed by `targetValue` in a single - * atomic performActions chain whose in-chain pause is exactly `gapMs`. Returns the value of the - * thought the cursor ends on (via getEditingText), so the caller can compare against the target. - * - * Both taps are dispatched together, from the NATIVE_APP context, with a finger-sized contact area so - * iOS processes them like physical taps. Dispatching them as one chain is the only way to reach the - * sub-100ms inter-tap gaps that two sequential `mobile: tap` calls (each ~375ms round-trip) cannot. - */ -const twoTap = async (targetValue: string, gapMs: number): Promise => { - const importText = ` +/** Seed three sibling thoughts a/b/c and settle the layout so tap coordinates resolve against a + * stable, keyboard-closed DOM. */ +const seedThoughts = async (): Promise => { + await paste(` - a - b - - c` - await paste(importText) + - c`) await waitForEditable('c') - - // Stabilize layout so the touch coordinates resolve against a settled DOM. await browser.execute(() => window.scrollTo(0, 0)) await browser.pause(400) +} - const a = await waitForEditable('a') - const target = await waitForEditable(targetValue) +/** The value of the thought whose editable is at the given CLIENT (viewport) point, or a marker + * describing what else is there. Used to verify a tap coordinate lands on the intended thought + * without actually tapping (elementFromPoint uses client coordinates, same as getElementRect). */ +const editableValueAtClientPoint = (x: number, y: number): Promise => + browser.execute( + (px: number, py: number) => { + const el = document.elementFromPoint(px, py) as HTMLElement | null + if (!el) return null + const editable = (el.closest('[data-editable]') || el.querySelector('[data-editable]')) as HTMLElement | null + if (editable) return editable.innerHTML + // Not on an editable β€” report the tag/testid so failures are diagnosable. + return `` + }, + x, + y, + ) - // Cache the Safari webview container's screen offset ONCE (native touches use absolute screen - // coordinates). The offset is a property of the browser chrome, not the page, so it is constant. - const offset = await getNativeElementRect('//XCUIElementTypeOther[@name="em"]') +/** Resolve a thought's client + screen tap centers, and HARD-ASSERT that its client center actually + * lands on that thought's editable (geometry verification). Fails loudly with the mismatching value + * if the coordinate would hit the wrong element. */ +const resolveVerifiedTapTarget = async (value: string, safariOffset: { x: number; y: number }): Promise => { + const editable: Element = await waitForEditable(value) + const rect = await browser.getElementRect(editable.elementId) + const client = { x: Math.round(rect.x + rect.width / 2), y: Math.round(rect.y + rect.height / 2) } + const screen = { x: Math.round(client.x + safariOffset.x), y: Math.round(client.y + safariOffset.y) } - /** Absolute screen center of an editable, using the cached container offset. */ - const screenCenter = async (editable: Element): Promise => { - const rect = await browser.getElementRect(editable.elementId) - return { - x: Math.round(rect.x + offset.x + rect.width / 2), - y: Math.round(rect.y + offset.y + rect.height / 2), - } - } + const hit = await editableValueAtClientPoint(client.x, client.y) + console.info( + `#4173 resolve "${value}": client (${client.x},${client.y}) hits "${hit}" | screen (${screen.x},${screen.y})`, + ) + expect(hit).toBe(value) + + return { value, client, screen } +} - // Resolve both centers up front, while the keyboard is still closed. Because both taps fire in a - // single sub-second action chain, the keyboard has not yet opened when the target is tapped, so its - // pre-keyboard coordinate is the correct one at chain-execution time. - const aPoint = await screenCenter(a) - const targetPoint = await screenCenter(target) +/** Fire a single native tap at an absolute screen point (finger-sized contact), from the NATIVE_APP + * context, then return to the webview. */ +const nativeTap = async (screen: { x: number; y: number }): Promise => { + const webviewContext = (await browser.getContext()) as string + await browser.switchContext('NATIVE_APP') + await browser.performActions([ + { + type: 'pointer', + id: 'finger1', + parameters: { pointerType: 'touch' }, + actions: [ + { type: 'pointerMove', duration: 0, x: screen.x, y: screen.y, origin: 'viewport', ...CONTACT }, + { type: 'pointerDown', button: 0, ...CONTACT }, + { type: 'pause', duration: HOLD_MS }, + { type: 'pointerUp', button: 0 }, + ], + }, + ]) + await browser.switchContext(webviewContext) +} +/** Fire two native taps β€” on `first` then `second` β€” in a single atomic performActions chain whose + * in-chain pause is exactly `gapMs`. Dispatching them as one chain is the only way to reach sub-100ms + * inter-tap gaps that two sequential native taps (each ~375ms round-trip) cannot. */ +const nativeTwoTap = async ( + first: { x: number; y: number }, + second: { x: number; y: number }, + gapMs: number, +): Promise => { const webviewContext = (await browser.getContext()) as string await browser.switchContext('NATIVE_APP') const t0 = Date.now() @@ -88,12 +135,12 @@ const twoTap = async (targetValue: string, gapMs: number): Promise${targetValue} gap ${gapMs}ms: received "${received}" (chain ${elapsed}ms)`) - return received + return elapsed } -describe('Caret', () => { - // Native-gesture taps at a genuine sub-second gap (>=25ms) DO move the cursor to the adjacent - // thought. These pass on BrowserStack, confirming that synthetic native taps at these intervals - // cannot reproduce #4173. The gap is the exact in-chain pause between the two taps. - const ADJACENT_GAPS_MS = [25, 50, 75, 100] - ADJACENT_GAPS_MS.forEach(gapMs => { - it(`Set caret on adjacent thought ${gapMs}ms after tap (#4173)`, async () => { - expect(await twoTap('b', gapMs)).toBe('b') +describe('Caret tap-landing calibration', () => { + // Prove that a single native tap at each thought's resolved screen center actually moves the cursor + // to that thought. This validates the coordinate math and the native touch path BEFORE any two-tap + // repro attempt, so a later "cursor did not move" result cannot be blamed on a mistargeted tap. + const VALUES = ['a', 'b', 'c'] + VALUES.forEach(value => { + it(`single native tap lands on "${value}"`, async () => { + await seedThoughts() + const safariOffset = await getNativeElementRect('//XCUIElementTypeOther[@name="em"]') + const target = await resolveVerifiedTapTarget(value, safariOffset) + + await nativeTap(target.screen) + await browser.pause(1000) + + const received = await getEditingText() + console.info(`#4173 calibration single tap "${value}": cursor now on "${received}"`) + expect(received).toBe(value) }) }) +}) + +describe('Caret #4173 two-tap repro', () => { + /** + * Seeds a/b/c, verifies BOTH tap points land on their intended thoughts (geometry), then taps `a` + * followed by `targetValue` in a single atomic chain with an exact `gapMs` gap. Returns the value + * the cursor ends on. Because both landings are verified first, the returned value is trustworthy: + * `a` = the second tap did not move the cursor (candidate #4173 / dropped tap), `targetValue` = it + * moved correctly, anything else = the two taps were coalesced/misrouted by the touch layer. + */ + const twoTap = async (targetValue: string, gapMs: number): Promise => { + await seedThoughts() + const safariOffset = await getNativeElementRect('//XCUIElementTypeOther[@name="em"]') + + // Resolve + verify both tap points up front, while the keyboard is closed. Both taps fire in one + // sub-second chain, so the keyboard has not opened when the target is tapped β€” the pre-keyboard + // coordinate is the one in effect at chain-execution time. + const aTarget = await resolveVerifiedTapTarget('a', safariOffset) + const target = await resolveVerifiedTapTarget(targetValue, safariOffset) + + const elapsed = await nativeTwoTap(aTarget.screen, target.screen, gapMs) + + // Give the app a moment to dispatch setCursor and re-render before reading the cursor position. + await browser.pause(1000) + const received = await getEditingText() + console.info(`#4173 twoTap a->${targetValue} gap ${gapMs}ms: received "${received}" (chain ${elapsed}ms)`) + return received + } - // SKIPPED β€” synthetic-input artifact, not a faithful #4173 repro (kept for documentation). - // At a literal 0ms in-chain gap, WDA/WebKit spatially COALESCES the two synthetic touches into a - // single averaged tap: a->b lands back on "a", and the non-adjacent control a->c lands on the - // geometric MIDPOINT "b" (not "c"). Genuine #4173 is a rapid-tap FOCUS-suppression bug that WDA - // taps bypass entirely (they force focus onto the target). The faithful, handler-level regression - // test is `#4173` in src/components/__tests__/Editable.ts. These two cases are skipped so CI stays - // green while still recording the exact artifact boundary. - it.skip('Set caret on adjacent thought 0ms after tap (#4173 β€” synthetic artifact, see Editable.ts)', async () => { - expect(await twoTap('b', 0)).toBe('b') + // Adjacent case (a->b). With landing verified, a "b" result means both taps hit correctly and the + // cursor moved (no repro at this gap); an "a" result means the verified-correct second tap failed to + // move the cursor β€” a genuine #4173 signal. Asserting "b" makes CI go red only if the bug actually + // reproduces at that gap. + const ADJACENT_GAPS_MS = [17, 34, 50, 75, 100] + ADJACENT_GAPS_MS.forEach(gapMs => { + it(`adjacent tap a->b at ${gapMs}ms lands and moves the cursor (#4173)`, async () => { + expect(await twoTap('b', gapMs)).toBe('b') + }) }) - it.skip('Set caret on non-adjacent thought 0ms after tap (#4173 control β€” synthetic artifact)', async () => { - expect(await twoTap('c', 0)).toBe('c') + // Non-adjacent control (a->c). Both points are verified to land on a and c, so if the cursor ends on + // anything other than "c" β€” notably the midpoint "b" β€” the atomic chain itself coalesced/misrouted + // the taps. This is the direct guard the investigation was missing: it distinguishes a real focus + // bug from a synthetic-input artifact. + const CONTROL_GAPS_MS = [17, 34, 50] + CONTROL_GAPS_MS.forEach(gapMs => { + it(`non-adjacent control a->c at ${gapMs}ms lands and moves the cursor`, async () => { + expect(await twoTap('c', gapMs)).toBe('c') + }) }) }) From 951f1cf07540e02be259ba0dbca2fc01fa3e1a07 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Thu, 9 Jul 2026 13:51:43 -0700 Subject: [PATCH 18/18] test(ios): reach 250ms independent-tap window via two pointer sources (#4173) Replace the two-tap driver with a single performActions call using two distinct pointer sources (finger1, finger2) on one shared W3C clock, pause-padded so the two taps fire ~250ms apart (the real-device inter-tap gap). One round-trip gives precise timing without the ~390ms per-command dispatch floor, and two distinct sources dodge WebKit's double-tap recognizer that merged the earlier single-source atomic chain. At this exact real-device timing the cursor still moves correctly (a->b -> b, a->c -> c), conclusively confirming synthetic WDA taps force focus onto the target regardless of timing. #4173 is a real-finger focus-suppression bug and is unreachable via synthetic input; the faithful regression guard remains the handler-level test in Editable.ts. The spec now stands as a harness/landing sanity check and executable documentation of that limit. Also add a priming-tap retry to stabilize the non-adjacent control. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/e2e/iOS/__tests__/caretAdjacentTap.ts | 261 ++++++++++++++++------ 1 file changed, 189 insertions(+), 72 deletions(-) diff --git a/src/e2e/iOS/__tests__/caretAdjacentTap.ts b/src/e2e/iOS/__tests__/caretAdjacentTap.ts index 0849c3b95d9..6364ce3709b 100644 --- a/src/e2e/iOS/__tests__/caretAdjacentTap.ts +++ b/src/e2e/iOS/__tests__/caretAdjacentTap.ts @@ -10,12 +10,16 @@ * be a dropped/missed second tap rather than the bug. At a 0ms in-chain gap the two touches were even * spatially COALESCED into a single averaged tap (a->c landed on the geometric midpoint b). * - * This spec fixes the blind spot: every tap point is verified to land on the correct element before it - * is used, at two levels. (1) Geometry: `document.elementFromPoint` at the tap's client center must - * resolve to the intended thought (deterministic, perturbs no state). (2) Native touch: a single real - * native tap on each thought in isolation must move the cursor to it, proving the SCREEN coordinates - * land correctly through the actual touch path. Only then are the two-tap repro attempts run, so their - * outcomes are trustworthy. + * This spec fixes the blind spot on two fronts. (A) Preconditions: #4173 only manifests when the + * keyboard is ALREADY open (so `state.isKeyboardOpen` routes the second tap into handleTapBehavior's + * defer-to-onFocus no-op branch), so each two-tap attempt first PRIMES the keyboard by tapping a + * neutral thought, then β€” because opening the keyboard scrolls the thoughts up β€” scrolls back to the + * top and RE-RESOLVES the pair at their keyboard-open positions. (B) Landing verification: every tap + * point is verified to hit the intended thought, at two levels β€” geometry (`document.elementFromPoint` + * at the tap's client center must resolve to the intended thought) and native touch (a single real + * native tap on each thought in isolation must move the cursor to it). Only then are the two-tap + * attempts run, so a "cursor moved" / "cursor stuck" outcome is trustworthy rather than a mistargeted + * or coalesced tap. * * Coordinate spaces (critical): in the webview context `browser.getElementRect` and * `document.elementFromPoint` both use CLIENT (viewport) CSS coordinates. Native `performActions` @@ -24,10 +28,22 @@ * * Isolated into its own spec file so it can be pinned to a specific iOS version (see * wdio.browserstack.conf.ts). The rest of the iOS suite runs on the default device/version. + * + * CONCLUSIVE RESULT: the two-tap block below reaches the exact real-device regime β€” two INDEPENDENT + * rapid taps ~250ms apart (the v6 real-device inter-tap gap), fired as ONE performActions call with + * two distinct pointer sources so they dodge WebKit's double-tap recognizer that had merged the + * single-source atomic chain β€” and the cursor STILL moves correctly to the second thought (a->b -> b, + * a->c -> c). This is the most faithful synthetic mechanism achievable, at the precise timing #4173 + * needs, and it does not reproduce the bug. It confirms that synthetic WDA taps force focus onto the + * target regardless of timing, so #4173 (a real-finger focus-suppression bug) is UNREACHABLE via + * synthetic input. These tests therefore assert the CORRECT behavior (both taps land and move the + * cursor); they stand as a harness/landing sanity check and as executable documentation of that limit. + * The faithful regression guard is the handler-level test in src/components/__tests__/Editable.ts. */ import type { Element } from 'webdriverio' import getEditingText from '../helpers/getEditingText' import getNativeElementRect from '../helpers/getNativeElementRect' +import hideKeyboardByTappingDone from '../helpers/hideKeyboardByTappingDone' import paste from '../helpers/paste' import waitForEditable from '../helpers/waitForEditable' @@ -50,14 +66,26 @@ interface TapTarget { screen: { x: number; y: number } } +// Inter-tap gap for the two-tap sequence. 250ms matches the ~249ms tap-to-tap interval measured when +// #4173 was reproduced by hand on a real device (on-device instrumentation, see session notes) β€” the +// realistic window a human hits, not the sub-100ms gaps that WDA spatially coalesces into one tap. +const TAP_GAP_MS = 250 + /** Seed three sibling thoughts a/b/c and settle the layout so tap coordinates resolve against a - * stable, keyboard-closed DOM. */ + * stable, keyboard-CLOSED, scroll-0 DOM. A prior tap (e.g. a calibration tap) leaves the keyboard + * open, which pushes the thoughts up and out of view; dismiss it and scroll back to the top so every + * test starts from the same known geometry. */ const seedThoughts = async (): Promise => { await paste(` - a - b - c`) await waitForEditable('c') + // Dismiss any lingering keyboard from a previous interaction before measuring coordinates β€” an open + // keyboard scrolls the thoughts out of view and invalidates the resolved tap points. + if (await browser.isKeyboardShown()) { + await hideKeyboardByTappingDone() + } await browser.execute(() => window.scrollTo(0, 0)) await browser.pause(400) } @@ -97,30 +125,78 @@ const resolveVerifiedTapTarget = async (value: string, safariOffset: { x: number return { value, client, screen } } +/** A finished single-tap gesture at one screen point: pointer down, brief hold, pointer up. */ +const singleTapGesture = (point: { x: number; y: number }, pointerId: string) => [ + { + type: 'pointer' as const, + id: pointerId, + parameters: { pointerType: 'touch' as const }, + actions: [ + { type: 'pointerMove' as const, duration: 0, x: point.x, y: point.y, origin: 'viewport', ...CONTACT }, + { type: 'pointerDown' as const, button: 0, ...CONTACT }, + { type: 'pause' as const, duration: HOLD_MS }, + { type: 'pointerUp' as const, button: 0 }, + ], + }, +] + +/** + * Two native taps β€” on `first` then `second`, `gapMs` apart β€” as a SINGLE performActions call using + * TWO SEPARATE pointer sources (finger1, finger2) on one shared W3C clock. This is the key to reaching + * the ~250ms window: one round-trip means the gap is chain-controlled and precise (no ~390ms + * command-dispatch floor), while using two DISTINCT pointer sources β€” rather than one finger tapping + * twice β€” aims to dodge WebKit's double-tap recognizer that merged the single-source atomic chain into + * a gesture. The two sources' action lists are padded to equal length so their ticks align: finger1 + * taps at t=0, finger2 taps at t=HOLD+gapMs (i.e. gapMs after finger1's release). + */ +const twoSourceTwoTap = (first: { x: number; y: number }, second: { x: number; y: number }, gapMs: number) => { + /** A pointerMove action to the given point (zero duration, finger-sized contact). */ + const move = (p: { x: number; y: number }) => ({ + type: 'pointerMove' as const, + duration: 0, + x: p.x, + y: p.y, + origin: 'viewport', + ...CONTACT, + }) + const down = { type: 'pointerDown' as const, button: 0, ...CONTACT } + const up = { type: 'pointerUp' as const, button: 0 } + /** A pause action of the given duration, used to align the two sources' ticks. */ + const pause = (duration: number) => ({ type: 'pause' as const, duration }) + return [ + { + type: 'pointer' as const, + id: 'finger1', + parameters: { pointerType: 'touch' as const }, + // T0 move, T1 down, T2 hold, T3 up, T4 gap, T5-T8 idle (padding to align with finger2). + actions: [move(first), down, pause(HOLD_MS), up, pause(gapMs), pause(0), pause(0), pause(HOLD_MS), pause(0)], + }, + { + type: 'pointer' as const, + id: 'finger2', + parameters: { pointerType: 'touch' as const }, + // Idle through finger1's tap + gap, then tap. Starts with a positioning move (no contact) so + // WDA's "pause must be preceded by pointerMove" rule is satisfied; down happens at T6. + actions: [move(second), pause(0), pause(HOLD_MS), pause(0), pause(gapMs), pause(0), down, pause(HOLD_MS), up], + }, + ] +} + /** Fire a single native tap at an absolute screen point (finger-sized contact), from the NATIVE_APP * context, then return to the webview. */ const nativeTap = async (screen: { x: number; y: number }): Promise => { const webviewContext = (await browser.getContext()) as string await browser.switchContext('NATIVE_APP') - await browser.performActions([ - { - type: 'pointer', - id: 'finger1', - parameters: { pointerType: 'touch' }, - actions: [ - { type: 'pointerMove', duration: 0, x: screen.x, y: screen.y, origin: 'viewport', ...CONTACT }, - { type: 'pointerDown', button: 0, ...CONTACT }, - { type: 'pause', duration: HOLD_MS }, - { type: 'pointerUp', button: 0 }, - ], - }, - ]) + await browser.performActions(singleTapGesture(screen, 'finger1')) await browser.switchContext(webviewContext) } -/** Fire two native taps β€” on `first` then `second` β€” in a single atomic performActions chain whose - * in-chain pause is exactly `gapMs`. Dispatching them as one chain is the only way to reach sub-100ms - * inter-tap gaps that two sequential native taps (each ~375ms round-trip) cannot. */ +/** + * Fire two native taps β€” on `first` then `second`, `gapMs` apart β€” from the NATIVE_APP context in a + * single two-pointer-source performActions call (see twoSourceTwoTap for why). Returns the wall-clock + * duration of the performActions call for logging. Because the timing is chain-controlled, the actual + * inter-tap gap is gapMs by construction, not the returned wall-clock value. + */ const nativeTwoTap = async ( first: { x: number; y: number }, second: { x: number; y: number }, @@ -129,24 +205,7 @@ const nativeTwoTap = async ( const webviewContext = (await browser.getContext()) as string await browser.switchContext('NATIVE_APP') const t0 = Date.now() - await browser.performActions([ - { - type: 'pointer', - id: 'finger1', - parameters: { pointerType: 'touch' }, - actions: [ - { type: 'pointerMove', duration: 0, x: first.x, y: first.y, origin: 'viewport', ...CONTACT }, - { type: 'pointerDown', button: 0, ...CONTACT }, - { type: 'pause', duration: HOLD_MS }, - { type: 'pointerUp', button: 0 }, - { type: 'pause', duration: gapMs }, - { type: 'pointerMove', duration: 0, x: second.x, y: second.y, origin: 'viewport', ...CONTACT }, - { type: 'pointerDown', button: 0, ...CONTACT }, - { type: 'pause', duration: HOLD_MS }, - { type: 'pointerUp', button: 0 }, - ], - }, - ]) + await browser.performActions(twoSourceTwoTap(first, second, gapMs)) const elapsed = Date.now() - t0 await browser.switchContext(webviewContext) return elapsed @@ -173,52 +232,110 @@ describe('Caret tap-landing calibration', () => { }) }) +describe('Caret single tap with keyboard open', () => { + // The decisive control: does a SINGLE native tap move the cursor when the keyboard is ALREADY open? + // Prime the keyboard by tapping `c`, scroll back to the top, re-resolve+verify the target, then tap + // it ONCE. If the cursor moves to the target, synthetic taps DO deliver focus with the keyboard open + // and any two-tap breakage is specific to the rapid pair (closer to #4173). If the cursor stays on + // the primer `c`, synthetic taps simply do not deliver focus while the keyboard is open on this + // harness β€” a limitation, not the bug. + const TARGETS = ['a', 'b'] + TARGETS.forEach(value => { + it(`single tap on "${value}" with keyboard open (primed by c) moves the cursor`, async () => { + await seedThoughts() + const safariOffset = await getNativeElementRect('//XCUIElementTypeOther[@name="em"]') + + const primer = await resolveVerifiedTapTarget('c', safariOffset) + await nativeTap(primer.screen) + await browser.waitUntil(async () => browser.isKeyboardShown(), { + timeout: 10000, + timeoutMsg: 'keyboard did not open after priming tap on "c"', + }) + + await browser.execute(() => window.scrollTo(0, 0)) + await browser.pause(400) + const target = await resolveVerifiedTapTarget(value, safariOffset) + + await nativeTap(target.screen) + await browser.pause(1000) + + const received = await getEditingText() + console.info(`#4173 keyboard-open single tap "${value}" (primer c): cursor now on "${received}"`) + expect(received).toBe(value) + }) + }) +}) + describe('Caret #4173 two-tap repro', () => { /** - * Seeds a/b/c, verifies BOTH tap points land on their intended thoughts (geometry), then taps `a` - * followed by `targetValue` in a single atomic chain with an exact `gapMs` gap. Returns the value - * the cursor ends on. Because both landings are verified first, the returned value is trustworthy: - * `a` = the second tap did not move the cursor (candidate #4173 / dropped tap), `targetValue` = it - * moved correctly, anything else = the two taps were coalesced/misrouted by the touch layer. + * Reproduces the #4173 preconditions, then attempts the bug. First taps `primerValue` to OPEN the + * keyboard and park the cursor away from the measured pair β€” #4173 only manifests when + * `state.isKeyboardOpen` is already true, which routes the second tap into handleTapBehavior's + * defer-to-onFocus no-op branch. Opening the keyboard scrolls the thoughts up, so the view is + * scrolled back to the top and `first`/`second` are RE-RESOLVED (and re-verified) at their + * keyboard-open positions before the rapid pair fires. Then taps `first` then `second` in one atomic + * chain with an exact `gapMs` gap and returns the value the cursor ends on: `second` = the pair + * moved the cursor correctly (no repro); `first` = the second tap fired but did not move the cursor + * (candidate #4173); anything else = the second tap did not land where intended. */ - const twoTap = async (targetValue: string, gapMs: number): Promise => { + const primeAndTwoTap = async ( + primerValue: string, + firstValue: string, + secondValue: string, + gapMs: number, + ): Promise => { await seedThoughts() const safariOffset = await getNativeElementRect('//XCUIElementTypeOther[@name="em"]') - // Resolve + verify both tap points up front, while the keyboard is closed. Both taps fire in one - // sub-second chain, so the keyboard has not opened when the target is tapped β€” the pre-keyboard - // coordinate is the one in effect at chain-execution time. - const aTarget = await resolveVerifiedTapTarget('a', safariOffset) - const target = await resolveVerifiedTapTarget(targetValue, safariOffset) + // Prime: tap a neutral thought to open the keyboard (and set the cursor there). This establishes + // the keyboard-open precondition #4173 requires, without touching the measured pair. Retry the tap + // once if the keyboard does not open (an occasional first-tap miss right after resetApp). + const primer = await resolveVerifiedTapTarget(primerValue, safariOffset) + await nativeTap(primer.screen) + try { + await browser.waitUntil(async () => browser.isKeyboardShown(), { timeout: 5000 }) + } catch { + await nativeTap(primer.screen) + await browser.waitUntil(async () => browser.isKeyboardShown(), { + timeout: 8000, + timeoutMsg: `keyboard did not open after priming tap on "${primerValue}"`, + }) + } + + // The keyboard scrolled the thoughts out of view. Scroll back to the top and RE-RESOLVE the pair + // at their current, keyboard-open positions so both taps land correctly (the pre-keyboard + // coordinates are now stale). + await browser.execute(() => window.scrollTo(0, 0)) + await browser.pause(400) + const firstTarget = await resolveVerifiedTapTarget(firstValue, safariOffset) + const secondTarget = await resolveVerifiedTapTarget(secondValue, safariOffset) - const elapsed = await nativeTwoTap(aTarget.screen, target.screen, gapMs) + const elapsed = await nativeTwoTap(firstTarget.screen, secondTarget.screen, gapMs) // Give the app a moment to dispatch setCursor and re-render before reading the cursor position. await browser.pause(1000) const received = await getEditingText() - console.info(`#4173 twoTap a->${targetValue} gap ${gapMs}ms: received "${received}" (chain ${elapsed}ms)`) + console.info( + `#4173 prime "${primerValue}" then ${firstValue}->${secondValue} gap ${gapMs}ms: received "${received}" (measured gap ${elapsed}ms)`, + ) return received } - // Adjacent case (a->b). With landing verified, a "b" result means both taps hit correctly and the - // cursor moved (no repro at this gap); an "a" result means the verified-correct second tap failed to - // move the cursor β€” a genuine #4173 signal. Asserting "b" makes CI go red only if the bug actually - // reproduces at that gap. - const ADJACENT_GAPS_MS = [17, 34, 50, 75, 100] - ADJACENT_GAPS_MS.forEach(gapMs => { - it(`adjacent tap a->b at ${gapMs}ms lands and moves the cursor (#4173)`, async () => { - expect(await twoTap('b', gapMs)).toBe('b') - }) + // Adjacent pair a->b with the keyboard already open (primed by tapping c). This is the faithful + // #4173 scenario, driven by two independent pointer sources ~250ms apart (see nativeTwoTap). On a + // real device the second tap's focus is suppressed and the cursor sticks on "a" (the bug); with + // synthetic WDA taps focus is forced onto "b", so the cursor moves and we get "b". Asserting "b" + // documents that synthetic input cannot reproduce #4173 even at the exact real-device timing, and + // guards that the verified taps actually take effect (no coalescing / no dropped tap). + it(`adjacent tap a->b at ${TAP_GAP_MS}ms with keyboard open moves the cursor (#4173)`, async () => { + expect(await primeAndTwoTap('c', 'a', 'b', TAP_GAP_MS)).toBe('b') }) - // Non-adjacent control (a->c). Both points are verified to land on a and c, so if the cursor ends on - // anything other than "c" β€” notably the midpoint "b" β€” the atomic chain itself coalesced/misrouted - // the taps. This is the direct guard the investigation was missing: it distinguishes a real focus - // bug from a synthetic-input artifact. - const CONTROL_GAPS_MS = [17, 34, 50] - CONTROL_GAPS_MS.forEach(gapMs => { - it(`non-adjacent control a->c at ${gapMs}ms lands and moves the cursor`, async () => { - expect(await twoTap('c', gapMs)).toBe('c') - }) + // Non-adjacent control a->c with the keyboard already open (primed by tapping b). Non-adjacent + // re-taps deliver focus on a real device, so this should reach "c". If it does while a->b sticks on + // "a", that is a genuine #4173 reproduction (adjacency-specific focus suppression), not a synthetic + // artifact. + it(`non-adjacent control a->c at ${TAP_GAP_MS}ms with keyboard open moves the cursor`, async () => { + expect(await primeAndTwoTap('b', 'a', 'c', TAP_GAP_MS)).toBe('c') }) })