[lexical-table] Bug Fix: don't throw on stale table node keys in $handleTableSelectionChangeCommand#8674
Open
etrepum wants to merge 1 commit into
Open
[lexical-table] Bug Fix: don't throw on stale table node keys in $handleTableSelectionChangeCommand#8674etrepum wants to merge 1 commit into
etrepum wants to merge 1 commit into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…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
2a3cbd1 to
4041fab
Compare
potatowagon
reviewed
Jun 11, 2026
potatowagon
left a comment
Contributor
There was a problem hiding this comment.
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:
$handleTableSelectionChangeCommandnow uses$getNodeByKey(nullable) instead of$getTableNodeByKeyOrThrowfor theshouldCheckSelectionForTablepath. 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$isTableNodeguard 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 thedestroyedmutation). UsesArray.from()before iteration to safely mutate during the loop — correct. - Encapsulation improvements:
removeObserver,removeAllObservers, and$getTableNodesAndObserversare properly marked@internaland 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),discreteupdates for synchronous assertions. - www backwards compatibility: No exports are removed or renamed. The new methods are
@internalonTableObserversclass. The$getTableNodeByKeyOrThrowimport removal inLexicalTableSelectionHelpers.tsis local — that function is still exported from@lexical/tablefor 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
The
SELECTION_CHANGE_COMMANDhandler dereferenced table node keys with$getNodeByKeyOrThrowin 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"):shouldCheckSelectionForTablekey recorded on ArrowDown (the Firefox workaround for scrollable tables) when the table is removed before the next selection change.tableObserversregistry sweep, when aTableNode'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
shouldCheckSelectionForTablelookup now uses$getNodeByKeyand skips missing nodes, and the registry maintenance is centralized in theTableObserversclass 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 unregistrationTableObservers.$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 registryTableObserversis not exported from the package, so the public API is unchanged.Closes #8670
Test plan
New unit tests