Skip to content

[lexical-table] Bug Fix: don't throw on stale table node keys in $handleTableSelectionChangeCommand#8674

Open
etrepum wants to merge 1 commit into
facebook:mainfrom
etrepum:claude/sleepy-allen-23qxk1
Open

[lexical-table] Bug Fix: don't throw on stale table node keys in $handleTableSelectionChangeCommand#8674
etrepum wants to merge 1 commit into
facebook:mainfrom
etrepum:claude/sleepy-allen-23qxk1

Conversation

@etrepum

@etrepum etrepum commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator

Description

The SELECTION_CHANGE_COMMAND handler dereferenced table node keys with $getNodeByKeyOrThrow in two places where the key can be stale, throwing invariant #63 ("Expected node with key %s to exist but it's not in the nodeMap"):

  • The shouldCheckSelectionForTable key recorded on ArrowDown (the Firefox workaround for scrollable tables) when the table is removed before the next selection change.
  • The tableObservers registry sweep, when a TableNode's destroyed mutation was missed (e.g. an update performed while the editor's root element was detached skips reconciliation), leaving a permanently poisoned registry that threw on every subsequent selection change.

The shouldCheckSelectionForTable lookup now uses $getNodeByKey and skips missing nodes, and the registry maintenance is centralized in the TableObservers class so it self-heals instead of erroring for the rest of the session:

  • TableObservers.removeObserver(tableKey) is the canonical teardown (removeListeners + registry delete) used by the mutation listener (destroyed and DOM-recreated branches)
  • TableObservers.removeAllObservers() handles plugin unregistration
  • TableObservers.$getTableNodesAndObservers() returns a snapshot of the registry for the current editor state, pruning entries whose table no longer exists so a missed destroyed mutation can't poison the registry

TableObservers is not exported from the package, so the public API is unchanged.

Closes #8670

Test plan

New unit tests

@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 Jun 11, 2026
@vercel

vercel Bot commented Jun 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, Comment Jun 11, 2026 4:41am
lexical-playground Ready Ready Preview, Comment Jun 11, 2026 4:41am

Request Review

…dleTableSelectionChangeCommand

The SELECTION_CHANGE_COMMAND handler dereferenced table node keys with
$getNodeByKeyOrThrow in two places where the key can be stale, throwing
invariant facebook#63 ("Expected node with key %s to exist but it's not in the
nodeMap"):

- The shouldCheckSelectionForTable key recorded on ArrowDown (the
  Firefox workaround for scrollable tables) when the table is removed
  before the next selection change.
- The tableObservers registry sweep, when a TableNode's destroyed
  mutation was missed (e.g. an update performed while the editor's root
  element was detached skips reconciliation), leaving a permanently
  poisoned registry that threw on every subsequent selection change.

The shouldCheckSelectionForTable lookup now uses $getNodeByKey and
skips missing nodes, and the registry maintenance is centralized in the
TableObservers class so it self-heals instead of erroring for the rest
of the session:

- TableObservers.removeObserver(tableKey) is the canonical teardown
  (removeListeners + registry delete) used by the mutation listener
  (destroyed and DOM-recreated branches)
- TableObservers.removeAllObservers() handles plugin unregistration
- TableObservers.$getTableNodesAndObservers() returns a snapshot of the
  registry for the current editor state, pruning entries whose table no
  longer exists so a missed destroyed mutation can't poison the registry

TableObservers is not exported from the package index, so the new
methods are internal and the public API is unchanged. The regression
tests drive both scenarios through the public API with an editor built
from TableExtension.

Fixes facebook#8670

https://claude.ai/code/session_01V5ShLDk437pCFXFjQtRewR

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

Reviewed by Navi (Tater Thoughts Bobblehead) on behalf of @potatowagon.

LGTM ✅ — This is a well-structured defensive fix for a real edge case (stale table node keys in the observer registry).

What I checked:

  • Logic correctness: $handleTableSelectionChangeCommand now uses $getNodeByKey (nullable) instead of $getTableNodeByKeyOrThrow for the shouldCheckSelectionForTable path. This correctly handles the case where a table is removed before its pending selection-change fires — instead of throwing, it gracefully skips the stale reference. The $isTableNode guard before proceeding is the right pattern.
  • Self-healing registry ($getTableNodesAndObservers): Iterates all entries and prunes stale ones (where the node no longer exists) before returning. This prevents "poisoned registry" issues when a table is removed while the root element is detached (missing the destroyed mutation). Uses Array.from() before iteration to safely mutate during the loop — correct.
  • Encapsulation improvements: removeObserver, removeAllObservers, and $getTableNodesAndObservers are properly marked @internal and centralize cleanup logic that was previously inlined in multiple places. No new public API surface.
  • Edge cases: The two new unit tests cover both scenarios — (1) ArrowDown records a table key, table is removed, selection change fires without throwing; (2) table removed while root detached, subsequent selection changes self-heal the registry and new tables still register correctly.
  • Test coverage: 112-line test file with proper setup/teardown, uses buildEditorFromExtensions + TableExtension (modern API), discrete updates for synchronous assertions.
  • www backwards compatibility: No exports are removed or renamed. The new methods are @internal on TableObservers class. The $getTableNodeByKeyOrThrow import removal in LexicalTableSelectionHelpers.ts is local — that function is still exported from @lexical/table for external consumers. No breaking change.
  • CI: All checks green — browser tests (mac/linux/windows × chromium/firefox/webkit), unit tests (Node 22.x/24.x), e2e (plain/rich/collab/collab-v2), integration, integrity, CLA, Vercel.

Assessment: Clean defensive fix. The pattern of swapping OrThrow for nullable-with-guard is well-established in Lexical for handling stale references. The self-healing $getTableNodesAndObservers is a nice addition that prevents accumulation of leaked observers. Ready to approve.

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: $handleTableSelectionChangeCommand throws invariant #63 on stale table node keys (poisoned tableObservers registry)

3 participants