Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
03e1f4c
test(ios): add repro test for setting caret on adjacent thought (#4173)
ethan-james Jul 7, 2026
9366295
test(ios): reproduce #4173 with native fat-taps on iOS 18
ethan-james Jul 7, 2026
fe12df0
test(ios): drop touch pressure to avoid force-press error on iPhone 16
ethan-james Jul 7, 2026
c53c83f
test(ios): sweep inter-tap intervals to probe #4173 trigger window
ethan-james Jul 7, 2026
86eba7c
test(ios): reproduce #4173 via native WebKit taps (mobile: tap)
ethan-james Jul 7, 2026
9a95970
test(ios): use screen coords for native mobile: tap in #4173 repro
ethan-james Jul 7, 2026
a555192
test(ios): pre-resolve tap coords to hit #4173 sub-second window
ethan-james Jul 7, 2026
cc3de77
test(ios): cache Safari offset so both taps land within #4173 window
ethan-james Jul 7, 2026
89a538c
test(ios): single-chain two-tap to probe #4173 double-tap window
ethan-james Jul 7, 2026
b44a674
test(ios): add non-adjacent control + boundary sweep to verify #4173 …
ethan-james Jul 7, 2026
925e76a
debug(editable): instrument tap->focus->setCursor flow for #4173 [tem…
ethan-james Jul 7, 2026
b530311
test(#4173): add handler-level regression test; remove instrumentatio…
ethan-james Jul 7, 2026
e7d7f68
test(#4173): use plain it() characterization assertion instead of it.…
ethan-james Jul 7, 2026
78bcd7c
test(#4173): add positive control to make the repro non-tautological
ethan-james Jul 7, 2026
7634abc
test(#4173): add non-adjacent tap control (keyboard open)
ethan-james Jul 8, 2026
d2928be
test(#4173): remove circular synthetic-focus control
ethan-james Jul 8, 2026
4bbb047
test(ios): verify each synthetic tap lands on the correct thought (#4…
ethan-james Jul 9, 2026
951f1cf
test(ios): reach 250ms independent-tap window via two pointer sources…
ethan-james Jul 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions src/components/__tests__/Editable.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -78,3 +83,90 @@ it('inserts emoji spacing immediately and allows Backspace at the emoji boundary

expect(editable.textContent).toBe('🧠Hello')
})

// 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. 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 `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.
//
// 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 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,
}: {
target: string
isKeyboardOpen: boolean
}): Promise<string | undefined> => {
await dispatch([
importText({
text: `
- a
- b
- c
`,
}),
])
await act(vi.runOnlyPendingTimersAsync)

// 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)

const editable = (await findThoughtByText(target))!
expect(editable).toBeTruthy()

// Fire touchend only — iOS suppresses the focus event on a rapid adjacent re-tap.
await act(async () => {
fireEvent.touchEnd(editable)
})
await act(vi.runOnlyPendingTimersAsync)

const state = store.getState()
return state.cursor ? headValue(state, state.cursor) : undefined
}

describe('#4173: tapping a thought after another', () => {
// 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 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 })).toBe('b')
})

// #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 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')
})
})
Loading
Loading