Skip to content

[lexical-table] Bug Fix: Fix unreliable text cursor placement when tapping table cells on touch devices - #8827

Open
Arman-Luthra wants to merge 1 commit into
facebook:mainfrom
Arman-Luthra:fix-table-touch-tap-selection
Open

[lexical-table] Bug Fix: Fix unreliable text cursor placement when tapping table cells on touch devices#8827
Arman-Luthra wants to merge 1 commit into
facebook:mainfrom
Arman-Luthra:fix-table-touch-tap-selection

Conversation

@Arman-Luthra

Copy link
Copy Markdown

Description

Current behavior: when a table is the sole content of the editor, placing the text cursor by tapping table cells on a touch device fails intermittently (#8538) — instead of a caret, a multi-cell table selection is created (which the next tap then clears, so to the user taps appear to do nothing).

Root cause: $handleTableClick deliberately skips $setAnchorCellForSelection for pointerType === 'touch' ("Touch taps should not initiate table selection mode", from #7309/#7656). However, #8081 later added a fallback in createPointerHandlers that sets the anchor cell unconditionally at pointerdown, which silently defeated that guard:

  1. A touch tap on cell A now sets tableObserver.anchorCell = A and initializes tableObserver.tableSelection, and this state persists after pointerup — nothing clears it for a plain tap.
  2. Real-world taps commonly include micro pointermove events between pointerdown and pointerup. On the next tap, on cell B, that micro-move resolves focusCell = B, and $handleTableSelectionChangeCommand sees anchorCell (A) !== focusCell (B) with an initialized tableSelection, so it calls $setFocusCellForSelection(B, true) — turning a simple tap into a TableSelection from A to B and preventing the caret from being placed.
  3. The same stale anchor also corrupts genuine touch drags: after tapping A, dragging from B to C selects A..C instead of B..C.

This PR (all new logic is gated on pointerType === 'touch'; mouse paths are unchanged):

Closes #8538

Test plan

Added regression tests in packages/lexical-table/src/__tests__/unit/LexicalTableMobileSelection.test.tsx that register the real registerTableSelectionObserver pointer handlers and simulate touch gestures (including the micro pointermove) against a 3x3 empty table as the sole document content.

Before

On main, the two new regression tests fail (verified across 3 consecutive runs):

× touch taps on different cells should not create a table selection
  (a TableSelection from the previously tapped cell is created)
× touch drag after a previous tap should anchor at the cell where the drag started
  (the selection is anchored at the stale cell from the previous tap)

After

pnpm vitest run --project unit packages/lexical-table — all 127 tests pass (new test file verified across 3 consecutive runs):

✓ a single touch tap with micro pointermove should not create a table selection
✓ touch taps on different cells should not create a table selection
✓ touch drag across cells should still create a table selection
✓ touch drag after a previous tap should anchor at the cell where the drag started

Also ran locally: packages/lexical-react unit tests (169 passed), packages/lexical unit tests (1199 tests; one unrelated fuzz-test timeout on a slow machine that behaves the same on main and passes in isolation), and ESLint/Prettier on the changed files.

Note: I was not able to run the Playwright e2e suite in my local environment, so this is verified at the unit level; happy to iterate if CI surfaces anything.

@vercel

vercel Bot commented Jul 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
lexical Ready Ready Preview Aug 1, 2026 1:25am
lexical-playground Ready Ready Preview Aug 1, 2026 1:25am

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Jul 11, 2026
@etrepum etrepum added the extended-tests Run extended e2e tests on a PR label Jul 11, 2026

@etrepum etrepum 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.

Here's some feedback from claude on the approach here, on a cursory read most of this seems accurate. Might also be worth noting that even if flushUpdates was necessary, the setTimeout stuff is superfluous. Any await at all will cause the resume to happen after the microtask that performs update reconciliation (that's what the old await editor.update(…) convention is for, from before {discrete: true} or editor.read('force-commit', …) were available)


Review notes on the approach

Ran the new tests against the reverted source change first: 2 of the 4 new tests fail without the fix (touch taps on different cells… and touch drag after a previous tap…), so they genuinely reproduce #8538. The fix is real, but verification turned up some gaps — the same symptom is still reachable through paths the tests don't cover, plus one behavioral regression.

Correctness

  1. No pointercancel/lostpointercapture handling (LexicalTableSelectionHelpers.ts:373). Cleanup is bound only to pointerup. A touch-scroll takeover fires pointercancel (no pointerup follows), leaving isSelecting=true and the stale closure installed. The next tap on a different cell early-returns at the isSelecting guard, its micro pointermove runs the stale closure, defeats the same-cell guard (different cell), and anchors at the old startingCell — recreating the exact #8538 selection the PR fixes.

  2. The untouched selectionchange branch (:1121) still converts tap→tap into a table selection. If the browser commits the caret while the finger is down (long-press caret placement, iOS Safari caret-on-touch in a focused editor), the branch sees collapsed selections in two different cells and builds an unwanted table selection regardless of the new anchor deferral. Reproduced with a unit-level test against this PR's code.

  3. Regression: the new (isTouch && !hasAnchorForGesture) re-anchor clause (:351) can clobber anchors legitimately set earlier in the same gesture. The old anchorCell === null guard made overwriting impossible. Victims: the selectionchange conversion handler (anchor A1 gets replaced by startingCell B1 on the next cross-cell move → B1→C1 instead of A1→C1) and the IS_FIREFOX shift-extend branch (X→C snaps to A→C).

  4. Same-cell guard has no pixel slop (:342). A tap near a cell border whose micro-move hit-tests into the neighboring cell bypasses focusCell.elem === startingCell.elem and produces a two-cell selection. Reproduced deterministically in this PR's own test harness.

  5. No pointerId filtering in onPointerMove (:297, pre-existing). A second finger's moves drive the first gesture's closure (anchoring at the first finger's cell → unwanted selection); a buttons=0 mouse hover during a touch drag tears the gesture down.

Design

The root cause of #8538 is gesture-scoped observer state (anchorCell, pointerType) never being reset at gesture end; the fix adds touch-only masks on top rather than fixing the lifecycle. A conditioned reset at pointerup/pointercancel — always reset pointerType; clear anchorCell/focusCell only when no table selection was established (they must persist for a live selection) — would make all four new tests pass by construction and also fixes items 1 and 3.

Tests

  • Stubbing document.elementsFromPoint in a jsdom unit test conflicts with AGENTS.md's testing strategy ("use browser tests … instead of stubbing the missing jsdom functionality"); it also hard-codes exactly the hit-testing geometry the bug involves. Consider __tests__/browser/.
  • flushUpdates (setTimeout) + await editor.read('latest', …) can be a single synchronous editor.read(cb) — default mode is 'force-commit', which flushes pending updates inline; read returns synchronously so the awaits are dead.
  • simulateTouchTap is a verbatim copy of simulateTouchDrag(cell, cell); dispatchPointerEvent duplicates the existing simulatePointerEvent in the same file; the hand-rolled 3×3 table is $createTableNodeWithDimensions(3, 3, false).

Minor

  • Every read of hasAnchorForGesture is behind isTouch &&, so the !isTouch && … initializer (:277) and the write at :288 are dead — let hasAnchorForGesture = false; is behavior-identical.
  • The new startingCell !== null guards (:341, :354) protect an impossible case (single call site always passes non-null); on that hypothetical path the tap suppression silently disables. Narrowing the param type to TableDOMCell removes them.

Checked and found not to be problems: pen input (eager re-anchor + the same-cell guard in $handleTableSelectionChangeCommand close every entry point, so pen matches mouse), and reordering the tap guard before the hit-test (implicit touch pointer capture pins moveEvent.target, so a target-based pre-check would break drag selection — the coordinate hit-test must run first).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. extended-tests Run extended e2e tests on a PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: Setting text cursor by tapping on a table cell within a single table as the whole content doesn't work reliably on mobile

2 participants