[lexical-table] Bug Fix: Fix unreliable text cursor placement when tapping table cells on touch devices - #8827
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
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
-
No
pointercancel/lostpointercapturehandling (LexicalTableSelectionHelpers.ts:373). Cleanup is bound only topointerup. A touch-scroll takeover firespointercancel(nopointerupfollows), leavingisSelecting=trueand the stale closure installed. The next tap on a different cell early-returns at theisSelectingguard, its micro pointermove runs the stale closure, defeats the same-cell guard (different cell), and anchors at the oldstartingCell— recreating the exact #8538 selection the PR fixes. -
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. -
Regression: the new
(isTouch && !hasAnchorForGesture)re-anchor clause (:351) can clobber anchors legitimately set earlier in the same gesture. The oldanchorCell === nullguard made overwriting impossible. Victims: the selectionchange conversion handler (anchor A1 gets replaced bystartingCellB1 on the next cross-cell move → B1→C1 instead of A1→C1) and theIS_FIREFOXshift-extend branch (X→C snaps to A→C). -
Same-cell guard has no pixel slop (
:342). A tap near a cell border whose micro-move hit-tests into the neighboring cell bypassesfocusCell.elem === startingCell.elemand produces a two-cell selection. Reproduced deterministically in this PR's own test harness. -
No
pointerIdfiltering inonPointerMove(:297, pre-existing). A second finger's moves drive the first gesture's closure (anchoring at the first finger's cell → unwanted selection); abuttons=0mouse 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.elementsFromPointin 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 synchronouseditor.read(cb)— default mode is'force-commit', which flushes pending updates inline;readreturns synchronously so theawaits are dead.simulateTouchTapis a verbatim copy ofsimulateTouchDrag(cell, cell);dispatchPointerEventduplicates the existingsimulatePointerEventin the same file; the hand-rolled 3×3 table is$createTableNodeWithDimensions(3, 3, false).
Minor
- Every read of
hasAnchorForGestureis behindisTouch &&, so the!isTouch && …initializer (:277) and the write at:288are dead —let hasAnchorForGesture = false;is behavior-identical. - The new
startingCell !== nullguards (: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 toTableDOMCellremoves 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).
…pping table cells on touch devices
3e84b78 to
05bbfad
Compare
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:
$handleTableClickdeliberately skips$setAnchorCellForSelectionforpointerType === 'touch'("Touch taps should not initiate table selection mode", from #7309/#7656). However, #8081 later added a fallback increatePointerHandlersthat sets the anchor cell unconditionally atpointerdown, which silently defeated that guard:tableObserver.anchorCell = Aand initializestableObserver.tableSelection, and this state persists afterpointerup— nothing clears it for a plain tap.pointermoveevents betweenpointerdownandpointerup. On the next tap, on cell B, that micro-move resolvesfocusCell = B, and$handleTableSelectionChangeCommandseesanchorCell (A) !== focusCell (B)with an initializedtableSelection, so it calls$setFocusCellForSelection(B, true)— turning a simple tap into aTableSelectionfrom A to B and preventing the caret from being placed.This PR (all new logic is gated on
pointerType === 'touch'; mouse paths are unchanged):pointerdownfor touch, restoring the [lexical-table] Bug Fix: Fix table selection for touch devices #7656 guarantee while keeping the [lexical][@lexical/table] Bug Fix: Fix inconsistent multi-cell selection in 2x2 tables #8081 fallback for mouse (the bug [lexical][@lexical/table] Bug Fix: Fix inconsistent multi-cell selection in 2x2 tables #8081 fixed was mouse drag selection).onPointerMove, ignores movement that stays within the cell the touch gesture started on, so taps with micro-moves no longer dispatch a next-focus off stale state.Closes #8538
Test plan
Added regression tests in
packages/lexical-table/src/__tests__/unit/LexicalTableMobileSelection.test.tsxthat register the realregisterTableSelectionObserverpointer 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):After
pnpm vitest run --project unit packages/lexical-table— all 127 tests pass (new test file verified across 3 consecutive runs):Also ran locally:
packages/lexical-reactunit tests (169 passed),packages/lexicalunit tests (1199 tests; one unrelated fuzz-test timeout on a slow machine that behaves the same onmainand 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.