Skip to content

[lexical-table] Bug Fix: Odd selection behavior with table as last element in document - #8858

Open
Jynx2004 wants to merge 18 commits into
facebook:mainfrom
Jynx2004:new_br_selection
Open

[lexical-table] Bug Fix: Odd selection behavior with table as last element in document#8858
Jynx2004 wants to merge 18 commits into
facebook:mainfrom
Jynx2004:new_br_selection

Conversation

@Jynx2004

Copy link
Copy Markdown
Contributor

Description

  • Prevent event propagation when navigating forward from root or shadow root in table selection.
  • The cursor goes beneath the table when pressing the right arrow from the last cell. And then stay there rather than cycle back up in the case when no other element is present below the table and table is the last element.
Screen.Recording.2026-07-18.152021.mp4

Closes #7999

@vercel

vercel Bot commented Jul 18, 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 Jul 21, 2026 7:38am
lexical-playground Ready Ready Preview Jul 21, 2026 7:38am

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 18, 2026
@mayrang

mayrang commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

offset > 0 catches too much. Consider two tables back-to-back: [Table1][Table2]. The cursor between them is at root offset 1. Table1's handler sees offset > 0, stops the event, and now you can't arrow into Table2. I verified this with a unit test — placing selection at root offset 1 between two tables and dispatching KEY_ARROW_RIGHT_COMMAND returns handled = true with your code (event swallowed), vs false without it.

The fix should confirm we're at a dead end after this table:

if (direction === 'forward') {
  const focusNode = selection.focus.getNode();
  if (
    $isRootOrShadowRoot(focusNode) &&
    selection.focus.offset === focusNode.getChildrenSize() &&
    focusNode.getLastChild() === tableNode
  ) {
    stopEvent(event);
    return true;
  }
}

offset === getChildrenSize() = past the last child (actual dead end). getLastChild() === tableNode = this handler's table is the thing we're stuck after.

Aside from that — the feedback on #8030 wasn't just about moving to the table plugin. The inline comments showed a precision pattern: the backward case right below checks $getBlockParentIfFirstNodegetPreviousSibling()$isTableNode(siblingNode) before acting. The new forward code skips that kind of validation entirely. Moving to the right file was necessary but the level of rigor should match what the rest of the function does.

Some other things:

  • The backward case (line 2183) branches on event.shiftKey to extend selection. The forward case blocks shift+right too without extending anything.
  • Every other return true in this function actively places the cursor (selectEnd(), selection.focus.set(...), etc). This one just stops the event.
  • No tests — a multi-table scenario would catch the regression.

The cursor geometry here is different from the backward case (root-level element selection vs cursor inside a paragraph), but the principle is the same: verify the table relationship before stopping the event.

@Jynx2004

Copy link
Copy Markdown
Contributor Author

offset > 0 catches too much. Consider two tables back-to-back: [Table1][Table2]. The cursor between them is at root offset 1. Table1's handler sees offset > 0, stops the event, and now you can't arrow into Table2. I verified this with a unit test — placing selection at root offset 1 between two tables and dispatching KEY_ARROW_RIGHT_COMMAND returns handled = true with your code (event swallowed), vs false without it.

The fix should confirm we're at a dead end after this table:

if (direction === 'forward') {
  const focusNode = selection.focus.getNode();
  if (
    $isRootOrShadowRoot(focusNode) &&
    selection.focus.offset === focusNode.getChildrenSize() &&
    focusNode.getLastChild() === tableNode
  ) {
    stopEvent(event);
    return true;
  }
}

offset === getChildrenSize() = past the last child (actual dead end). getLastChild() === tableNode = this handler's table is the thing we're stuck after.

Aside from that — the feedback on #8030 wasn't just about moving to the table plugin. The inline comments showed a precision pattern: the backward case right below checks $getBlockParentIfFirstNodegetPreviousSibling()$isTableNode(siblingNode) before acting. The new forward code skips that kind of validation entirely. Moving to the right file was necessary but the level of rigor should match what the rest of the function does.

Some other things:

  • The backward case (line 2183) branches on event.shiftKey to extend selection. The forward case blocks shift+right too without extending anything.
  • Every other return true in this function actively places the cursor (selectEnd(), selection.focus.set(...), etc). This one just stops the event.
  • No tests — a multi-table scenario would catch the regression.

The cursor geometry here is different from the backward case (root-level element selection vs cursor inside a paragraph), but the principle is the same: verify the table relationship before stopping the event.

Hey @mayrang i think the code you wrote is more reliable . Just one thing is that getLastChild function is deprecated so if we remove that condition i think it still works fine with the first 2 conditions to check the dead end.

@etrepum

etrepum commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

getLastChild(): null | LexicalNode is not deprecated, what is deprecated is the form with a generic type argument.

@Jynx2004

Jynx2004 commented Jul 18, 2026

Copy link
Copy Markdown
Contributor Author

getLastChild(): null | LexicalNode is not deprecated, what is deprecated is the form with a generic type argument.

(focusNode as ElementNode).__last === tableNode.__key
Hey @etrepum . Is this a better condition ?

@etrepum

etrepum commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Absolutely not. getLastChild() is the correct method to call. It is not deprecated. Calling getLastChild<UnsafeCast>() is the only deprecation.

Co-authored-by: Bob Ippolito <bob@redivi.com>

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

This PR still needs tests before it can be considered for merge. There is nothing that shows what it's supposed to do or tests that it works.

@Jynx2004

Copy link
Copy Markdown
Contributor Author

This PR still needs tests before it can be considered for merge. There is nothing that shows what it's supposed to do or tests that it works.

Will add some tests to confirm the behavior

@Jynx2004

Copy link
Copy Markdown
Contributor Author

This PR still needs tests before it can be considered for merge. There is nothing that shows what it's supposed to do or tests that it works.

Added added a test case to check the expected behavior

@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 are some notes from Claude on the implementation and current approach, overall it looks pretty good but a few things can be tightened up


Review: PR #8858 — Odd selection behavior with table as last element

✅ Verification: tests fail without the fix

I applied the PR's two files and ran the new test both ways:

State Result
Fix + test applied 8/8 pass
Test applied, source fix reverted 1 failArrowRight at root end after a table… fails at expect(secondHandled).toBe(true) (received false)

The test genuinely exercises the change. One nuance: only the second ArrowRight assertion depends on the fix — the first firstHandled returns true from pre-existing code even without the change, so the new branch's coverage rests entirely on the secondHandled assertion.

The fix itself is narrow and correct: when a range selection's focus is at the root/shadow-root end (offset === childrenSize) and the last child is this tableNode, forward navigation is swallowed so the caret stays beneath the table — matching the expected behavior in #7999. Using tableNode.is(...) (key comparison) is the right choice for node versioning.

Findings (all minor — none blocks the fix)

1. Missing isCollapsed() guard on the forward branchLexicalTableSelectionHelpers.ts:2142 (correctness)
The new block returns true/stopEvent whenever focus is at the root end after the table, without checking the selection is collapsed. A non-collapsed range selection (anchor before the table, focus at root offset === childrenSize) would normally collapse to its edge on ArrowRight; now it's swallowed. Shift+ArrowRight is likewise blocked from extending. Narrow, but reachable via block/table-level range selections. Consider gating on selection.isCollapsed().

2. Test placed under the wrong describeLexicalTableSelectionHelpers.test.ts:372 (test organization)
The new test is nested inside describe('DELETE_LINE_COMMAND in table cells'), so it reports under an unrelated heading. Suggest moving it to its own block, e.g. describe('regression #7999').

3. Forward-only fixLexicalTableSelectionHelpers.ts:2136 (altitude/completeness)
Only direction === 'forward' is handled. The issue repro is right-arrow-specific so scope is defensible, but ArrowDown out of the last cell reaches the same root-end position with no equivalent stop. Generalizing "caret at root end after last-child table" would be a deeper fix than a forward-only special case.

4. Redundant guard / eager computationLexicalTableSelectionHelpers.ts:2138 (simplification)
$isRootOrShadowRoot(focusNode) in the guard already implies focusNode is an ElementNode, so the $isElementNode(...) ? getLastChild() : null ternary is dead on the meaningful path and evaluates getLastChild() even for non-root focus nodes. Compute getLastChild() after confirming root:

if (direction === 'forward' && $isRootOrShadowRoot(focusNode)) {
  const focusNode = selection.focus.getNode();
  if (
    $isRootOrShadowRoot(focusNode) &&
    selection.focus.offset === focusNode.getChildrenSize() &&
    tableNode.is(focusNode.getLastChild())
  ) {
    stopEvent(event);
    return true;
  }
}

@Jynx2004

Copy link
Copy Markdown
Contributor Author

Here are some notes from Claude on the implementation and current approach, overall it looks pretty good but a few things can be tightened up


Review: PR #8858 — Odd selection behavior with table as last element

✅ Verification: tests fail without the fix

I applied the PR's two files and ran the new test both ways:

State Result
Fix + test applied 8/8 pass
Test applied, source fix reverted 1 failArrowRight at root end after a table… fails at expect(secondHandled).toBe(true) (received false)

The test genuinely exercises the change. One nuance: only the second ArrowRight assertion depends on the fix — the first firstHandled returns true from pre-existing code even without the change, so the new branch's coverage rests entirely on the secondHandled assertion.

The fix itself is narrow and correct: when a range selection's focus is at the root/shadow-root end (offset === childrenSize) and the last child is this tableNode, forward navigation is swallowed so the caret stays beneath the table — matching the expected behavior in #7999. Using tableNode.is(...) (key comparison) is the right choice for node versioning.

Findings (all minor — none blocks the fix)

1. Missing isCollapsed() guard on the forward branchLexicalTableSelectionHelpers.ts:2142 (correctness)
The new block returns true/stopEvent whenever focus is at the root end after the table, without checking the selection is collapsed. A non-collapsed range selection (anchor before the table, focus at root offset === childrenSize) would normally collapse to its edge on ArrowRight; now it's swallowed. Shift+ArrowRight is likewise blocked from extending. Narrow, but reachable via block/table-level range selections. Consider gating on selection.isCollapsed().

2. Test placed under the wrong describeLexicalTableSelectionHelpers.test.ts:372 (test organization)
The new test is nested inside describe('DELETE_LINE_COMMAND in table cells'), so it reports under an unrelated heading. Suggest moving it to its own block, e.g. describe('regression #7999').

3. Forward-only fixLexicalTableSelectionHelpers.ts:2136 (altitude/completeness)
Only direction === 'forward' is handled. The issue repro is right-arrow-specific so scope is defensible, but ArrowDown out of the last cell reaches the same root-end position with no equivalent stop. Generalizing "caret at root end after last-child table" would be a deeper fix than a forward-only special case.

4. Redundant guard / eager computationLexicalTableSelectionHelpers.ts:2138 (simplification)
$isRootOrShadowRoot(focusNode) in the guard already implies focusNode is an ElementNode, so the $isElementNode(...) ? getLastChild() : null ternary is dead on the meaningful path and evaluates getLastChild() even for non-root focus nodes. Compute getLastChild() after confirming root:

if (direction === 'forward' && $isRootOrShadowRoot(focusNode)) {
  const focusNode = selection.focus.getNode();
  if (
    $isRootOrShadowRoot(focusNode) &&
    selection.focus.offset === focusNode.getChildrenSize() &&
    tableNode.is(focusNode.getLastChild())
  ) {
    stopEvent(event);
    return true;
  }
}

Sure
Will take a look at this

@Jynx2004

Jynx2004 commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Findings (all minor — none blocks the fix)

1. Missing isCollapsed() guard on the forward branchLexicalTableSelectionHelpers.ts:2142 (correctness) The new block returns true/stopEvent whenever focus is at the root end after the table, without checking the selection is collapsed. A non-collapsed range selection (anchor before the table, focus at root offset === childrenSize) would normally collapse to its edge on ArrowRight; now it's swallowed. Shift+ArrowRight is likewise blocked from extending. Narrow, but reachable via block/table-level range selections. Consider gating on selection.isCollapsed().

2. Test placed under the wrong describeLexicalTableSelectionHelpers.test.ts:372 (test organization) The new test is nested inside describe('DELETE_LINE_COMMAND in table cells'), so it reports under an unrelated heading. Suggest moving it to its own block, e.g. describe('regression #7999').

3. Forward-only fixLexicalTableSelectionHelpers.ts:2136 (altitude/completeness) Only direction === 'forward' is handled. The issue repro is right-arrow-specific so scope is defensible, but ArrowDown out of the last cell reaches the same root-end position with no equivalent stop. Generalizing "caret at root end after last-child table" would be a deeper fix than a forward-only special case.

4. Redundant guard / eager computationLexicalTableSelectionHelpers.ts:2138 (simplification) $isRootOrShadowRoot(focusNode) in the guard already implies focusNode is an ElementNode, so the $isElementNode(...) ? getLastChild() : null ternary is dead on the meaningful path and evaluates getLastChild() even for non-root focus nodes. Compute getLastChild() after confirming root:

I have fixed 1st and 2nd issues . Regarding the 3rd point , here we are facing the issue only in the case of right arrow command and no other command . So this issue limits to only forward direction.

@etrepum

etrepum commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

There is a similar quirk with arrow down that we should also fix here, if you press down from the end of the document and then left or up then the selection does not land in the last table cell

@Jynx2004

Copy link
Copy Markdown
Contributor Author

There is a similar quirk with arrow down that we should also fix here, if you press down from the end of the document and then left or up then the selection does not land in the last table cell

I think this issue lies in the below code snippet
D:\Lexical\lexical\packages\lexical-table\src\LexicalTableSelectionHelpers.ts
Line no. 2303 -

 if (direction === 'down' && $isScrollableTablesActive(editor)) {
      // Enable Firefox workaround
      tableObservers.setShouldCheckSelectionForTable(tableNode.getKey());
    }

This makes SELECTION_CHANGE_COMMAND run two times on chromium as well where it should only run on firefox . It must be guarded by using IS_FIREFOX

Updated code -

 if (IS_FIREFOX && direction === 'down' && $isScrollableTablesActive(editor)) {
  tableObservers.setShouldCheckSelectionForTable(tableNode.getKey());
}

This fixes the issue . Would it be possible if i make a seperate PR for this issue ?

@etrepum

etrepum commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

It would be better to consolidate all of the known arrow key workarounds into one PR

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.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: Odd selection behavior with table as last element in document

3 participants