Skip to content

Put the caret where a tap landed when iOS reverts it after the touchend - #6002

Open
ethan-james wants to merge 4 commits into
mainfrom
copilot/fix/4220-tap-near-caret-revert
Open

ethan-james wants to merge 4 commits into
mainfrom
copilot/fix/4220-tap-near-caret-revert

Conversation

@ethan-james

@ethan-james ethan-james commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #4220. Replaces #5655 and #5983.

A tap a few characters from the caret gets no mouse events, so useEditMode's onMouseDown never places the caret. After the touchend, iOS moves the caret to the tapped word and then, within about 70ms, back to where it was. On BrowserStack every such tap reverted on iOS 17, 26 and 27, with no Selection call from em.

A new useRevertCorrection hook, mounted by useEditMode beside useCaretRestore on the thought being edited, records the caret offset and the touch point at the tap's touchend. A selectionchange that returns the caret to that offset after it moved away is taken as the revert, and the caret is placed at getCaretOffset of the touch point in the same handler. iOS does not move it again after the revert: without a frame's delay, all 14 corrected taps in a debug log from an iPhone on iOS 27 stayed where they were put. It only sets the selection. The cursor offset in Redux is left alone, as it is for a tap that iOS places itself, since nothing writes it during a tap without mouse events. The watch ends on the next touchstart, mousedown, keydown or beforeinput. The mousedown matters most, since it means the tap is one onMouseDown places itself. Unlike #5655's fixed 100ms timer, the hook reacts to the revert itself, so it does not depend on how long iOS takes.

The caret goes to em's own offset for the tap point, which can differ from the word edge iOS first chose (#5982).

Scope

This used to be based on #5983, which also corrected a revert that arrives before a touchend that iOS 27 withholds (#5981). #5981 is on hold until Safari fixes the withheld touch (#5660), so that case is out of scope. Without it, the watch no longer has to span the touch, which removes most of #5983:

  • no revertWatch state in touchStore; the watch is a local variable in the hook's effect
  • no listeners in initEvents; the hook's listeners exist only while the thought is being edited
  • no touchmove / TOUCH_SLOP check: a drag or long press has already moved the caret by its touchend, and nothing moves it back
  • no setCaretOffset hoist, store.getState() or setCursor dispatch, and useEditMode gains one line
  • no mount on notes, and no iOS 27 double-tap test

Adjacent behaviour

  • Taps with mouse events: the mousedown ends the watch, so onMouseDown keeps ownership.
  • Taps on another thought: the focus moves, and the hook only acts while its editable is document.activeElement.
  • Double tap, long press, loupe: ranges are ignored, and a press has moved the caret by its touchend.
  • Keyboard trackpad ([iOS] Caret disappears after using virtual keyboard trackpad #3276): has no touch on the page, so it never starts a watch.
  • Typing, undo, IME: each sends a keydown or beforeinput, which ends the watch.

Tests

caret.ts: "Tap moves the caret to a word a few characters away", Steps to Reproduce A from #4220, waiting with a new waitForCaretOffset helper that requires the offset on two samples 250ms apart. Without the hook mounted, it fails on BrowserStack on iOS 17 and 26 with "Expected the caret to come to rest at offset 23, but it was at 26".

Not yet checked on a physical device.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor
Preview Deployment · Oct 28, 2026, 6:43 PM UTC · 3c08c7d

Preview deployment

@ethan-james ethan-james self-assigned this Oct 16, 2026
@ethan-james
ethan-james marked this pull request as ready for review October 19, 2026 21:44

@trevinhofmann trevinhofmann left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM as an extension of #5983, thank you!

Comment thread src/e2e/iOS/__tests__/caret.ts Outdated

// place the caret before "the"
await tap(editable, { offset: 26, pointerType: 'touch' })
await waitForCaretOffset(26)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Might be helpful to use something such as 'Hello beautiful people in '.length rather than magic numbers in the test, but I think this is fine.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense! I wasn't sure how we felt about those string-based offset, but it should work fine.

@raineorshine

Copy link
Copy Markdown
Contributor

Is this intended to stack on #5983?

@ethan-james
ethan-james added this pull request to stack #6112 October 22, 2026 23:53
@ethan-james

Copy link
Copy Markdown
Collaborator Author

Is this intended to stack on #5983?

I think so. Last time I stacked something I had to undo it, so I haven't quite got my head around those. This one is finalized, though, so it makes sense.

@raineorshine

Copy link
Copy Markdown
Contributor

Thanks! Looks right on my end :)

image

A tap a few characters from the caret gets no mouse events, so onMouseDown
never places the caret, and after the touchend iOS moves the caret to the
tapped word and then back to where it was (#4220). useRevertCorrection, mounted
by useEditMode on the thought being edited, records the caret offset and the
touch point at the touchend, takes a selectionchange back to that offset after
a move away as the revert, and places the caret at getCaretOffset of the touch
point, dispatching setCursor so the Redux offset follows. The watch ends on the
next touchstart, mousedown, keydown or beforeinput.

This replaces #5983's revert correction. The case it was built for, a revert
before a touchend that iOS 27 withholds (#5981), is waiting on a Safari fix,
so the watch no longer spans the touch: it needs no touchStore state, no
listeners in initEvents and no TOUCH_SLOP check for drags.

Co-Authored-By: Claude claude-opus-5-5 (medium) <noreply@anthropic.com>
@ethan-james
ethan-james force-pushed the copilot/fix/4220-tap-near-caret-revert branch from e51362c to 8435ba0 Compare October 27, 2026 22:20

@raineorshine raineorshine left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, this one looks pretty manageable. useRevertCorrection is self contained, so at least the complexity is not spread over many files.


// iOS is still handling the tap when it fires this selectionchange.
requestAnimationFrame(() => {
selection.set(editable, { offset: tapped })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This deviates from the declarative design of useEditMode where we attempt to centralize selection.set and avoid competing selection logic. Many conditions feed into one write.

Is there something fundamentally different about useRevertCorrection that it can’t follow the existing pattern?
Would it be possible to integrate into useEditMode?

It may be cleaner the way you have it, but since it does seem to be a departure from our earlier architecture, along with useCaretRestore, so I want to make sure that we have a unified vision rather than patching on a new paradigm. Refactoring may be necessary if there is a better way to layer multiple effects and touch handlers.

@ethan-james ethan-james Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The detection can't fold into the declarative effect, because it reacts to iOS changing the selection rather than to app state, so a selectionchange listener has to live somewhere (as with useCaretRestore). The write could, in principle: dispatch setCursor with the tapped offset and let setSelectionToCursorOffset place the caret, as the retarget path in onTouchEnd does.

The blocker is that the selection effect deliberately doesn't re-run when only cursorOffset changes, so that it doesn't set the selection while typing. The retarget path gets a re-run because the cursor moves to a different thought. Here the thought is already the cursor, and the only existing way to force a re-run is editableNonce. That's global: every ContentEditable re-checks its HTML and every Note drops its draft. On the thought being edited, HTML in Redux that lags the DOM would also be written over the text. A dedicated trigger would avoid that, but it would be new state for a single caller.

So I'm looking at keeping the detection in the hook and routing the write through useEditMode's setCaretOffset, which onMouseDown already uses, so both tap paths share one placement.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like a new caretNonce dependency would be a pretty good solution for getting the declarative effect to run without messing up the editableNonce and risking losing typed changes. I think that this would be best handled in #4470, which I've de-prioritized but can re-prioritize now that the path forward seems clearer.

Anything decided here should be extended to cover useCaretRestore and, ideally, setCaretOffset as well. It would be great to keep state.cursorOffset in better sync by always calling setCursor rather than simply selection.set. However, there are some open questions about whether waiting for the Redux sync is fast enough for some of these caret adjustment operations to proceed without noticeable flicker. If I remember correctly, I was exploring replacing useEffect with useLayoutEffect, which is also a substantial change that needs a lot of testing.

For now, I think I'll drop the setCursor dispatch because it doesn't cover any new cases. If cursorOffset was up to date before the caret was reverted, then it will still be up to date after the correction. If it was stale due to lack of mouse events, then it can stay stale.

if (tapped === null || tapped === before) return

// iOS is still handling the tap when it fires this selectionchange.
requestAnimationFrame(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why requestAnimationFrame? I’m not sure there is any guarantee that the browser selection lines up with the frame rate.

Is there something more salient than the next frame that you could wait for? Or is it a magic number that might be sensitive to cpu load?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that I managed to get rid of it now that this solution is specific to #4220 and doesn't also try to fix #5981.

The correction no longer waits a frame. iOS does not move the caret again
after the revert, so setting it in the revert's selectionchange holds: the
caret.ts test for #4220 passed 9 of 9 times on BrowserStack on iOS 17, 26 and
27 without the frame, and on an iPhone on iOS 27 all 14 corrected taps in a
debug log stayed where they were put.

Co-Authored-By: Claude claude-opus-5-5 (medium) <noreply@anthropic.com>
The revert correction no longer dispatches setCursor. Nothing writes the cursor
offset in Redux during a tap that gets no mouse events, so a tap that iOS places
itself leaves it where it was, and the correction now does the same: it only
sets the selection. The hook no longer needs the thought's path.

Co-Authored-By: Claude claude-opus-5-5 (medium) <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview — 3c08c7d5 Deployed Oct 28, 2026 by github-actions[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[iOS] Caret is not moved to adjacent word when tapping in the proximity of 4 -5 characters

3 participants