Skip to content

[lexical-table] Bug Fix: Cover a tapped empty table cell on iOS so a double tap cannot select past it - #9350

Open
SujinKim1127 wants to merge 26 commits into
meta:mainfrom
SujinKim1127:fix/ios-table-cell-editing-host
Open

SujinKim1127 wants to merge 26 commits into
meta:mainfrom
SujinKim1127:fix/ios-table-cell-editing-host

Conversation

@SujinKim1127

@SujinKim1127 SujinKim1127 commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Description

On iOS, double tapping an empty table cell makes WebKit select back to the nearest preceding word, which lies outside the table. #9267 restores the caret after selectionchange, but iOS paints the native highlight before the page is notified, so the preceding content still flashes on every double tap (#9347). The second tap dispatches no cancelable event, and page CSS does not affect the highlight.

This PR first gave every cell its own editing host. Following review, it now uses a much smaller overlay instead; the wrapper, the empty-cell Backspace guard, and the window blur / touch marker changes are reverted.

Empty cell overlay (LexicalTableIOSEmptyCellOverlay.ts)

  • A touch tap in an empty cell covers that cell for 500ms with an unmanaged contenteditable="false" overlay. A second tap in that window lands on the overlay instead of editable text, so iOS does not start a word selection, and the overlay places the caret in the cell.
  • The cell is covered on the tap's pointerup, after a short, still touch. When the cell already holds the caret, iOS withholds that tap's pointerup until it knows whether a second tap follows, so that cell is covered on pointerdown instead.
  • Outside the window nothing covers the cell, so long press and long press drag keep working. A drag or long press elsewhere, and a touch iOS takes over (pointercancel), never adds it.
  • The overlay goes away when the window ends, another cell is tapped, or the cell gets content, and the cell's inline position is restored with it.
  • TableCellNode.getDOMSlot keeps the cell's children before the overlay with withBefore, so DOM child offsets still match the node's children. The overlay is never part of createDOM, so exported HTML is unchanged.

Drag caret (LexicalTableIOSDragCaret.ts)

  • During a long press drag across cells, iOS keeps moving the native caret while the table selection keeps clearing it, so it flickers.
  • The root's caret-color is made transparent while a finger is down on a cell, and restored (to any previous inline value) when the last finger lifts, including a lift on an element removed during the touch, such as the overlay. Only cells inside the editor count, so an editor that sits in a page's <td> is left alone. It leaves caret-color to core's block cursor when that cursor is shown or changed it during the gesture.
  • This reduces the flicker but does not remove it: the caret iOS draws around its magnifier is not affected by caret-color. I'm happy to split this into its own PR if you'd prefer.

Both are registered from registerTableSelectionObserver on iOS only. In LexicalTableSelectionHelpers, the #9267 repair now uses the shared $isCellEmpty (LexicalTableUtils) for its empty cell check, and TAP_SLOP moved to constants so the overlay can share it; neither changes behavior. Non-iOS behavior and DOM are unchanged.

Closes #9347

Test plan

IosEmptyCellOverlay.test.ts and IosTableDragCaret.test.ts mock IS_IOS.

Before

ScreenRecording_10-12-2026.16-51-09_1.mov

On an iPhone (iOS Safari) with the playground: double tapping an empty cell flashes the cells above and the end of the paragraph above the table. A long press drag across cells starting from an empty cell paints the cells with the caret flickering throughout.

With the lexical-table sources at main:

$ pnpm exec vitest run packages/lexical-table/src/__tests__/unit/IosEmptyCellOverlay.test.ts packages/lexical-table/src/__tests__/unit/IosTableDragCaret.test.ts
     × hides the caret while a finger is down on a cell
     × keeps hiding until the last finger lifts
     × restores a caret-color that was already set
     × a tap covers an empty cell for the double-tap window
     × the cell holding the caret is covered on pointerdown
     × a tap in a cell with text, or in another cell, uncovers
     × a tap on the overlay keeps focus and places the caret in its cell
     × a touch on the overlay that outlasts it still restores the caret
     × stays after managed children, out of exported HTML, and goes once the cell has text
      Tests  9 failed | 10 passed (19)

After

ScreenRecording_10-23-2026.15-34-02_1.mov

On the same iPhone with the playground:

  • Double tapping an empty cell, repeated at varying speeds, shows no highlight, including when the cell already holds the caret. An on-device event log confirmed the second tap lands on the overlay.
  • A single tap places the caret and opens the keyboard. Typing, including Korean, works.
  • A long press in an empty cell still shows the native menu, and the caret is visible again once the menu closes.
  • A long press drag starting from an empty cell paints the cells, with the caret flicker reduced but not gone.
  • Tapping the start cell right after a drag places the caret normally.
  • Tapping an empty cell that already holds the caret still shows the Paste menu.
  • A second tap just under half a second after the first, held past the end of the overlay window, shows the Paste menu, and the caret is visible after lifting, both when typing right away and after dismissing the menu.
$ pnpm exec vitest run packages/lexical-table/src/__tests__/unit/IosEmptyCellOverlay.test.ts packages/lexical-table/src/__tests__/unit/IosTableDragCaret.test.ts
      Tests  19 passed (19)
$ pnpm exec vitest run packages/lexical-table
 Test Files  35 passed (35)
      Tests  316 passed (316)
$ pnpm exec vitest run --project browser packages/lexical-table/src/__tests__/browser
 Test Files  7 passed (7)
      Tests  58 passed (58)
$ pnpm exec vitest run scripts/__tests__/unit/treeShakingSource.test.ts
      Tests  380 passed (380)
$ pnpm run tsc     # 0 errors
$ pnpm run flow    # Found 0 errors

Not exercised: iPadOS, Android (the code is iOS-only), and hardware keyboard navigation.

🤖 Generated with Claude Code

… host on iOS

## Description

On iOS, double tapping an empty table cell makes WebKit select back to the
nearest preceding word, which lies outside the table when the cell is empty.
meta#9267 restores the caret after `selectionchange`, which prevents the data
loss, but iOS paints the native selection highlight before the page is
notified, so the preceding content still flashes blue on every double tap
(meta#9347). Nothing dispatched to the page can cancel that gesture: the second
tap produces no cancelable event, and `::selection` / `user-select` do not
affect the UIKit highlight.

This change gives each table cell its own editing host on iOS so the word
search never leaves the cell. `TableCellNode.createDOM` renders
`<td contenteditable="false"><div data-lexical-table-cell-content
contenteditable="true">` and `getDOMSlot` returns `slot.withElement(wrapper)`,
so the reconciler places the cell's children inside the wrapper without any
model change. `exportDOM` strips the wrapper and the `contenteditable`
attribute, so copied and exported HTML keeps the existing `<td><p>` shape.
Non-iOS DOM is unchanged.

Two side effects of the nested host are handled in
`LexicalTableSelectionHelpers`:

- Backspace at the start of an empty cell would let WebKit move the caret
  into the previous editing host (the cell to the left). It is consumed for
  `deleteContentBackward` / `DELETE_CHARACTER_COMMAND` when the cell is empty
  and the caret is at its start.
- The iOS selection UI fires `window.blur` before `selectionchange`, which
  cleared the `touchTapCellKey` marker from meta#9267 before it could be used.
  The marker is now kept for up to 500ms across that blur when the tapped
  cell is an empty wrapper cell, and a native range touching that cell is
  collapsed back to the cell start.

Closes meta#9347

## Test plan

`IosTableCellEditingHost.test.ts` mocks `IS_IOS` (and `CAN_USE_BEFORE_INPUT`,
which jsdom lacks) and pins the wrapper DOM shape, wrapper identity across
added paragraphs, the editable toggle, the exported HTML, and the Backspace
guard in both the empty and the non-empty cell.

### Before

```
$ pnpm exec vitest run packages/lexical-table/src/__tests__/unit/IosTableCellEditingHost.test.ts
     × renders the cell content inside a nested editing host
     × keeps the same editing host as paragraphs are added
     × follows the editable state of the editor
     × consumes Backspace at the start of an empty cell
     × leaves Backspace alone in a cell with text
      Tests  5 failed | 1 passed (6)
```

On an iPhone (iOS Safari and iOS Chrome) with the playground: insert a table
below a paragraph, double tap an empty cell. The cells above and the end of
the paragraph above the table are highlighted blue for one frame, then the
caret returns to the cell.

### After

```
$ pnpm exec vitest run packages/lexical-table/src/__tests__/unit/IosTableCellEditingHost.test.ts
      Tests  6 passed (6)
$ pnpm exec vitest run packages/lexical-table
 Test Files  26 passed (26)
      Tests  265 passed (265)
$ pnpm exec vitest run --project browser packages/lexical-table/src/__tests__/browser/EmptyCellTouchSelection.test.ts
 Test Files  1 passed (1)
      Tests  30 passed (30)
$ pnpm run tsc        # 0 errors
$ pnpm exec eslint <changed files>   # ok
$ pnpm exec prettier --check <changed files>   # All matched files use Prettier code style!
```

Same device steps: nothing is highlighted and the caret stays in the cell.
Also verified on device: typing in a previously empty cell (English with
autocorrect, Korean IME), Enter creating several paragraphs, Backspace at an
empty cell start staying in the cell, double tap on a cell with text
selecting the word, selection-handle drag from the paragraph above into the
table and from a cell out of the table, long-press drag across cells
producing a table selection, tapping another cell moving the caret, table
scrolling.

Not exercised: arrow-key and Shift+arrow navigation across cells on iOS (no
hardware keyboard available), and Android.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
lexical Ready Ready Preview Nov 2, 2026 7:37am UTC
lexical-playground Ready Ready Preview Nov 2, 2026 7:37am UTC

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 Oct 13, 2026
@pkg-pr-new

pkg-pr-new Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
lexical

npm i https://pkg.pr.new/lexical@9350

@lexical/a11y

npm i https://pkg.pr.new/@lexical/a11y@9350

@lexical/clipboard

npm i https://pkg.pr.new/@lexical/clipboard@9350

@lexical/code

npm i https://pkg.pr.new/@lexical/code@9350

@lexical/code-core

npm i https://pkg.pr.new/@lexical/code-core@9350

@lexical/code-prism

npm i https://pkg.pr.new/@lexical/code-prism@9350

@lexical/code-shiki

npm i https://pkg.pr.new/@lexical/code-shiki@9350

@lexical/compiler

npm i https://pkg.pr.new/@lexical/compiler@9350

@lexical/devtools-core

npm i https://pkg.pr.new/@lexical/devtools-core@9350

@lexical/dragon

npm i https://pkg.pr.new/@lexical/dragon@9350

@lexical/eslint-plugin

npm i https://pkg.pr.new/@lexical/eslint-plugin@9350

@lexical/extension

npm i https://pkg.pr.new/@lexical/extension@9350

@lexical/fast-check

npm i https://pkg.pr.new/@lexical/fast-check@9350

@lexical/file

npm i https://pkg.pr.new/@lexical/file@9350

@lexical/hashtag

npm i https://pkg.pr.new/@lexical/hashtag@9350

@lexical/headless

npm i https://pkg.pr.new/@lexical/headless@9350

@lexical/history

npm i https://pkg.pr.new/@lexical/history@9350

@lexical/html

npm i https://pkg.pr.new/@lexical/html@9350

@lexical/internal

npm i https://pkg.pr.new/@lexical/internal@9350

@lexical/link

npm i https://pkg.pr.new/@lexical/link@9350

@lexical/list

npm i https://pkg.pr.new/@lexical/list@9350

@lexical/mark

npm i https://pkg.pr.new/@lexical/mark@9350

@lexical/markdown

npm i https://pkg.pr.new/@lexical/markdown@9350

@lexical/mdast

npm i https://pkg.pr.new/@lexical/mdast@9350

@lexical/offset

npm i https://pkg.pr.new/@lexical/offset@9350

@lexical/overflow

npm i https://pkg.pr.new/@lexical/overflow@9350

@lexical/plain-text

npm i https://pkg.pr.new/@lexical/plain-text@9350

@lexical/react

npm i https://pkg.pr.new/@lexical/react@9350

@lexical/rich-text

npm i https://pkg.pr.new/@lexical/rich-text@9350

@lexical/selection

npm i https://pkg.pr.new/@lexical/selection@9350

@lexical/table

npm i https://pkg.pr.new/@lexical/table@9350

@lexical/tailwind

npm i https://pkg.pr.new/@lexical/tailwind@9350

@lexical/text

npm i https://pkg.pr.new/@lexical/text@9350

@lexical/utils

npm i https://pkg.pr.new/@lexical/utils@9350

@lexical/y

npm i https://pkg.pr.new/@lexical/y@9350

@lexical/yjs

npm i https://pkg.pr.new/@lexical/yjs@9350

commit: 02eccf0

@meta-code-review meta-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks for reworking this around an overlay. That part is small, iOS-only, and keeps exported HTML and DOM child offsets intact through getDOMSlot(...).withBefore(...). One issue in the drag-caret module needs a fix before this can merge; the rest are optional suggestions.

Needs a fix: the caret can stay hidden after a touch (LexicalTableIOSDragCaret.ts:61)

registerIOSTableDragCaretHiding saves rootElement.style.caretColor at touchstart and writes that saved value back at touchend (and on teardown, line 77). The saved value is not always yours to restore. When the caret sits beside a block that cannot hold one, such as a table or a block decorator like an image, core shows the block cursor and sets caret-color: transparent. Removing the block cursor clears it again (removeDOMBlockCursorElement in packages/lexical/src/LexicalUtils.ts).

So if a long-press drag across cells starts while a block cursor is showing:

  1. touchstart saves transparent, the block cursor's value.
  2. The drag commits a table selection while the finger is down, which removes the block cursor and clears caret-color.
  3. touchend writes transparent back.

Nothing clears it after that, so the caret is invisible in every paragraph and cell until a block cursor is next shown and removed. A long press that places the caret in a cell does the same. A unit test on the same harness as IosTableDragCaret.test.ts shows it: after those three steps, plus selecting text in the paragraph above, caretColor is still 'transparent' on this branch and '' on main.

A possible fix: at touchend, leave caret-color alone if it is no longer the transparent this module wrote. If the saved value came from the block cursor, restore it only while the block cursor is still in the DOM, and otherwise remove the property. Please add that sequence as a test. Splitting this module into its own PR, as you offered, would also work.

Optional suggestions

  • LexicalTableIOSEmptyCellOverlay.ts:144: cellDOM.contains(domSelection.anchorNode) reads the raw selection anchor. The "Shadow DOM and iframe realm safety" table in AGENTS.md asks for getDOMSelectionPoints(selection, rootElement) instead, as core's own iOS click handling does. In an editor inside a shadow root the anchor is retargeted to the host, so the pointerdown path for a cell that already holds the caret would never run there.

  • Changeset: this changes what the published @lexical/table does on iOS, so AGENTS.md (Changesets) and CONTRIBUTING.md ask for an entry in .changeset/, with a Verify: paragraph for a fix that changes behaviour. For example:

    ---
    '@lexical/table': patch
    ---
    
    [fix] Double tapping an empty table cell on iOS no longer selects back to the text before the table
    
    Verify: on an iPhone, double tap an empty cell (also one that already holds the caret); single tap, long press and long-press drag across cells still behave as before.
  • Test plan output: the pasted Before block lists 18 tests ("9 failed | 9 passed") under names that are no longer in the files. At this head the two files hold 15 tests, and 8 of them fail against main. "Commits and Pull Requests" in AGENTS.md asks for the real Before and After output of the command that was run, so please re-paste it from the current files.

… iOS drag caret hiding ends

## Description

Addresses the review of this PR.

- The drag caret hiding saved the root's inline `caret-color` at `touchstart`
  and wrote it back at `touchend`. Core's block cursor writes the same
  property: `transparent` while shown, removed when it goes away. If a block
  cursor was showing at `touchstart` and the gesture moved the selection
  (a long-press drag committing a table selection), the block cursor went
  away mid-gesture and `touchend` then wrote its `transparent` back, leaving
  the caret hidden in the whole editor. The restore now leaves `caret-color`
  alone if something else changed it during the gesture or the block cursor
  (`[data-lexical-cursor]`) is shown, and removes the property instead of
  restoring a `transparent` that came from a block cursor that is gone. The
  same logic runs on teardown.
- The overlay reads the caret's cell through `getDOMSelectionPoints`, so a
  tap on the cell holding the caret is also covered on `pointerdown` in an
  editor inside a shadow root.
- Add a changeset for `@lexical/table`.

## Test plan

### Before

The new block cursor test against the previous restore:

```
$ pnpm exec vitest run packages/lexical-table/src/__tests__/unit/IosTableDragCaret.test.ts
     × does not restore a block cursor value that went away mid-gesture
      Tests  1 failed | 6 passed (7)
```

### After

```
$ pnpm exec vitest run packages/lexical-table/src/__tests__/unit/IosTableDragCaret.test.ts
      Tests  7 passed (7)
$ pnpm exec vitest run packages/lexical-table
 Test Files  31 passed (31)
      Tests  300 passed (300)
$ pnpm exec vitest run --project browser packages/lexical-table/src/__tests__/browser
      Tests  58 passed (58)
$ pnpm exec vitest run scripts/__tests__/unit/treeShakingSource.test.ts
      Tests  374 passed (374)
```

Each `ci-check` step (`tsc`, `tsc-test`, `tsc-scripts`, `tsc-extension`,
`tsc-website`, `flow`, `prettier`, `lint`, `check-changesets`) exits 0. Not
re-checked on a device; the block cursor sequence was reproduced in a unit
test, and the shadow root path was not exercised.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@meta-code-review meta-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks for reworking this around a small overlay, as suggested earlier in this thread. The overlay part reads well: it is iOS-only, keeps DOM child offsets and exported HTML intact through getDOMSlot(...).withBefore(...), and the new tests separate the fix from main. One issue in the drag-caret module needs a fix before this can merge; the rest are optional suggestions.

Needs a fix: the caret can stay hidden after a touch (LexicalTableIOSDragCaret.ts:61)

registerIOSTableDragCaretHiding saves rootElement.style.caretColor at touchstart and writes it back at touchend (and on teardown, line 77). That saved value is not always this module's to restore. When the caret sits beside a block that cannot hold one (a horizontal rule, an image, or a table), core shows the block cursor and sets caret-color: transparent; removing the block cursor clears it again (removeDOMBlockCursorElement in packages/lexical/src/LexicalUtils.ts).

So when a touch on a cell starts while a block cursor is showing:

  1. touchstart saves transparent, the block cursor's value.
  2. The caret moves into the table while the finger is down (a long press, or the table selection a long-press drag commits). That removes the block cursor and clears caret-color.
  3. touchend writes transparent back.

Nothing clears it after that, so the caret is invisible in every paragraph and cell until a block cursor is next shown and removed. On main the caret comes back as soon as the block cursor goes. The reverse case is off too: if the block cursor appears during the touch, touchend restores '' and the native caret shows beside the block cursor.

A possible fix: at touchend and on teardown, restore the saved value only while the root still has the transparent this module set, and leave caret-color alone while a block cursor is in the DOM. Please add the three steps above as a case in IosTableDragCaret.test.ts: put the caret beside the table with $getRoot().select(n, n) on a focused root so the block cursor shows, fire touchstart on a cell, move the selection into that cell, fire touchend, then expect caretColor not to be 'transparent'. Splitting this module into its own PR, as you offered, would also work.

Optional suggestions

  • LexicalTableIOSEmptyCellOverlay.ts:144: cellDOM.contains(domSelection.anchorNode) reads the raw selection anchor. The "Shadow DOM and iframe realm safety" table in AGENTS.md asks for getDOMSelectionPoints(selection, rootElement) instead, because a selection inside a shadow root is retargeted to the host. In a shadow-root editor the pointerdown path for a cell that already holds the caret never runs.

  • LexicalTableIOSDragCaret.ts:48: target.closest('td,th') can match a table cell outside the editor, for example when the editor itself sits inside a page's <td>, and then the caret is hidden on every touch. Checking rootElement.contains(cell), as the overlay module does, keeps it to the editor's own tables.

  • Changeset: this changes what the published @lexical/table does on iOS, so AGENTS.md (Changesets) and .changeset/README.md ask for an entry, with a Verify: paragraph for a fix that changes behaviour. For example:

    ---
    '@lexical/table': patch
    ---
    
    [fix] Double tapping an empty table cell on iOS no longer selects back to the text before the table
    
    Verify: on an iPhone, double tap an empty cell (also one that already holds the caret); single tap, long press and long-press drag across cells still behave as before.
  • Test plan: the pasted Before output lists 18 tests under names that no longer exist (for example only the most recently tapped cell is covered); the two files now hold 15. AGENTS.md (Commits and Pull Requests) asks for the real Before and After output of the command that was run, so please re-paste it from the current branch.

if (restoreCaretColor === null || event.touches.length > 0) {
return;
}
rootElement.style.caretColor = restoreCaretColor;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This writes back the value saved at touchstart, which can be the block cursor's caret-color: transparent. If the caret moved into the table during the touch, core has already removed the block cursor and cleared the property, so this leaves the caret invisible in the whole editor until a block cursor is shown and removed again. Restoring only while the root still has the transparent this module set, and never while a block cursor is in the DOM, avoids it (line 77 needs the same).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in af44fd2.

…e the editor

## Description

`registerIOSTableDragCaretHiding` hid the caret when `closest('td,th')` found any cell, so an editor that itself sits in a page's `<td>` hid its caret on every touch until the finger lifted. It now only hides it for a cell inside the editor root, as the empty cell overlay already does.

## Test plan

### Before

```
$ pnpm exec vitest run packages/lexical-table/src/__tests__/unit/IosTableDragCaret.test.ts
     × leaves the caret alone for an editor that sits in a page table cell 6ms
AssertionError: expected 'transparent' to be '' // Object.is equality
      Tests  1 failed | 7 passed (8)
```

### After

```
$ pnpm exec vitest run packages/lexical-table
 Test Files  35 passed (35)
      Tests  315 passed (315)
```

Not exercised on a device: an editor inside a page table cell.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@meta-code-review meta-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks for the quick follow-up on the block-cursor case and for adding the changeset. The restore now leaves caret-color alone when the block cursor owns it, and the new test fails against the previous version of the module, which is exactly what it should pin. One issue in the same module still needs a fix before this can merge; the other two points are optional.

Needs a fix: the caret can stay hidden after a touch that outlives the overlay (LexicalTableIOSDragCaret.ts:91-95)

registerIOSTableDragCaretHiding restores caret-color only from touchend/touchcancel listeners on the window. A touch's events stay targeted at the element the touch started on, even after that element is removed from the document, and then they no longer bubble to the window (MDN: Touch.target). The empty-cell overlay is removed by its 500 ms timer (LexicalTableIOSEmptyCellOverlay.ts:88 and :121), so a second touch that starts on the overlay and is still down when the window ends (a tap roughly 400 ms after the first, or a press-and-hold) lifts on a detached element. restore never runs, and the caret stays invisible while typing in the cell until some later touch on the page ends.

To reproduce in the playground on an iPhone: tap an empty cell, tap it again a little under half a second later and hold it slightly longer, then type. In iPhone-emulated Chromium with real touch input, the root keeps caret-color: transparent after the lift and after typing; on main the caret stays visible.

A small fix: in onTouchStart, also add {once: true} touchend and touchcancel listeners to the touch's own target, which is the practice MDN recommends when a touched element can be removed. Keeping the overlay until a touch that started on it ends would also work. A unit test along these lines (combining helpers from the two new test files, with fake timers) would pin it:

tapCell(0); // covers the empty cell
const overlay = overlayOf(0)!;
vi.advanceTimersByTime(420);
touch(overlay, 'touchstart'); // the caret is hidden
vi.advanceTimersByTime(150); // the window ends and the overlay is removed
touch(overlay, 'touchend'); // the lift reaches only the detached overlay
expect(container.style.caretColor).not.toBe('transparent');

If you would rather land the overlay on its own, splitting the drag-caret module into a separate PR, as you offered in the description, works too.

Optional

  • LexicalTableIOSDragCaret.ts:57-59: the removeProperty branch only runs when the saved transparent was not the block cursor's, because when the block cursor goes away mid-gesture core has already cleared the property and the early return at :51 handles it. So an app that hides the native caret with an inline caret-color: transparent on the root gets it back after any table-cell touch. Writing saved back in every case matches the description ("restored (to any previous inline value)") and keeps all seven drag-caret tests passing.
  • In the cell that already holds the caret, the overlay is added on pointerdown (LexicalTableIOSEmptyCellOverlay.ts:136-150), so that tap's click lands on the overlay. Tapping the insertion point is also how iOS shows the Paste menu; could you note under the Test plan's After whether that still works in an empty cell?

Comment on lines +91 to +95
registerEventListeners(
view,
{touchcancel: onTouchEnd, touchend: onTouchEnd},
options,
),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

These window listeners miss the lift when the touch started on an element that was removed during the touch: touch events stay targeted at the original element and no longer bubble to the window (MDN: Touch.target). The empty-cell overlay is removed by its 500 ms timer, so a second touch that starts on it and outlasts the window leaves caret-color: transparent on the root while the user types. Adding {once: true} touchend/touchcancel listeners to the touch's own target in onTouchStart fixes it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 02eccf0: the touch's own target now gets touchend/touchcancel listeners too.

Comment on lines +57 to +59
if (saved === 'transparent') {
// That was the block cursor's value, and it is gone.
rootElement.style.removeProperty('caret-color');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional: when the block cursor goes away mid-gesture, core has already removed the property, so the early return above handles it and this branch only runs when the saved transparent was not the block cursor's (for example an app's own inline value). Writing saved back here too keeps "any previous inline value" and the existing tests still pass.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 02eccf0: the saved value is always written back.

…overlay is removed mid-touch

## Description

`registerIOSTableDragCaretHiding` restored `caret-color` only from `touchend`/`touchcancel` listeners on the window. A touch keeps its original target even after that element leaves the document, and its events then no longer reach the window. The empty cell overlay is removed by its 500ms timer, so a second tap that started on it and was still down when the window ended left the caret hidden while typing in the cell. The lift is now also listened for on the touch's own target, and those listeners go away when the caret is restored.

The restore also writes the saved inline value back in every case. The branch that removed the property instead only ran when the saved `transparent` was not the block cursor's (an app's own inline value), because core clears the property itself when the block cursor goes away.

## Test plan

### Before

```
$ pnpm exec vitest run packages/lexical-table/src/__tests__/unit/IosEmptyCellOverlay.test.ts
     × a touch on the overlay that outlasts it still restores the caret 10ms
AssertionError: expected 'transparent' to be '' // Object.is equality
      Tests  1 failed | 10 passed (11)
```

### After

```
$ pnpm exec vitest run packages/lexical-table
 Test Files  35 passed (35)
      Tests  316 passed (316)
$ pnpm run tsc && pnpm run tsc-test   # 0 errors
$ pnpm run flow                       # Found 0 errors
```

Not exercised on a device yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@SujinKim1127

Copy link
Copy Markdown
Contributor Author

Thanks for the reviews. Everything from all three is addressed.

Required fixes

  • Caret left hidden when a block cursor is shown (af44fd2): the restore now runs only while the root still has the transparent this module set and no block cursor is in the DOM. Your sequence is a test now, along with the case where the block cursor stays shown.
  • Caret not restored when the overlay goes away mid-touch (02eccf0): as you suggested, touchstart also adds touchend/touchcancel listeners to the touch's own target, and they are removed when the caret is restored. Your test is in nearly as written, and it failed before the fix.

Optional suggestions

  • Switched to getDOMSelectionPoints(domSelection, rootElement).anchorNode. (af44fd2)
  • closest('td,th') matching a cell outside the editor: it now checks rootElement.contains, as the overlay module does. (9280cdf)
  • Dropped the removeProperty branch; the restore always writes the saved value back. (02eccf0)
  • Added a changeset for @lexical/table with a Verify: paragraph. (af44fd2)
  • Re-pasted the Test plan from the current files: the two files now hold 19 tests, and 9 of them fail against main.

Re-checked on an iPhone:

  • Tapping an empty cell, then tapping it again just under half a second later and holding, shows the Paste menu. After lifting, the caret is visible when typing right away, and also after dismissing the menu and tapping the cell again.
  • Tapping an empty cell that already holds the caret still shows the Paste menu.
  • Double tapping an empty cell (including one that holds the caret), typing after a tap (including Korean), long-press drag, and the caret after a drag all work as before.

A block cursor can't be shown by touch alone, so that case is covered by the unit tests.

@meta-code-review meta-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks for reworking this along the lines suggested earlier. This looks good to me: the overlay is a much smaller change than the per-cell editing host, JSON and exported HTML are unchanged, no public export changes, and the new tests fail on main and pass here. The points below are optional and non-blocking.

  1. Wrapped cell slots during the overlay window (packages/lexical-table/src/LexicalTableCellNode.ts, getDOMSlot). The cell's slot now gets the overlay as its before boundary. A TableCellNode subclass or a domOverride that wraps the cell's children the way packages/lexical-website/docs/serialization/dom-render.md ("Customizing the slot for an ElementNode") shows, with withElement(inner), keeps that boundary, so adding a block to the cell within the 500 ms window fails with DOMSlot.insertChild: before is not in element. getDOMSlot is experimental and nothing in the repository does this today, but a small guard in coverCell (skip the cell when cell.getDOMSlot(cellDOM).element !== cellDOM) would keep the overlay to cells that use the default slot.
  2. Scope of the caret hiding (packages/lexical-table/src/LexicalTableIOSDragCaret.ts, onTouchStart). The root's caret-color goes transparent for any touch that starts on a cell, including a long press that moves the caret inside a cell with text, or a scroll that starts on the table while the caret is in a paragraph elsewhere. Because it is set on the root, the in-page caret disappears wherever it is for the whole touch. Since the flicker comes from the table selection clearing the native caret, starting the hiding once a touch drag has turned into a table selection may be enough. Your offer to split this module into its own PR also sounds reasonable; it would let #9347 land with the overlay alone.
  3. A browser-tier test for the hit-test. AGENTS.md ("Testing Strategy") puts behavior that depends on real layout under packages/**/__tests__/browser/**. The jsdom tests cover the overlay's lifecycle well; a browser-mode test that mocks IS_IOS the same way, taps an empty cell and checks elementFromPoint at its centre would also cover the part of the fix that does not need an iPhone.
  4. Nit: aria-hidden="true" on the overlay would keep VoiceOver from treating the transient element as content.

// offsets still match the node's children.
const last = element.lastElementChild;
return last !== null && last.hasAttribute(EMPTY_CELL_OVERLAY_ATTRIBUTE)
? slot.withBefore(last)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional: a TableCellNode subclass or domOverride that wraps the cell's children with withElement(inner) (the pattern in packages/lexical-website/docs/serialization/dom-render.md) keeps this before, which is a child of the <td>, not of inner. Adding a block to the cell within the 500 ms window then fails DOMSlot.insertChild: before is not in element. Skipping the overlay in coverCell when cell.getDOMSlot(cellDOM).element !== cellDOM would avoid it.

return;
}
restoreCaretColor = rootElement.style.caretColor;
rootElement.style.caretColor = 'transparent';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional: this runs for every touch that starts on a cell, including a long press that moves the caret within a cell or a scroll that starts on the table, and since it is set on the root the caret is hidden wherever it is. Starting the hiding once a touch drag has become a table selection may be enough for the flicker this targets.

clear();
const overlay = rootElement.ownerDocument.createElement('div');
overlay.setAttribute(EMPTY_CELL_OVERLAY_ATTRIBUTE, 'true');
overlay.contentEditable = 'false';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit: overlay.setAttribute('aria-hidden', 'true') would keep VoiceOver from treating the transient overlay as content.

This branch was successfully deployed

2 active deployments
Preview – lexical — 02eccf0d Deployed Nov 2, 2026 by vercel[bot]
Preview – lexical-playground — 02eccf0d Deployed Nov 2, 2026 by vercel[bot]
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: Double tap in an empty table cell on iOS briefly highlights the preceding content

2 participants