[lexical-list] Bug Fix: Preserve previous DecoratorNode on Backspace at the start of a top-level list#8676
[lexical-list] Bug Fix: Preserve previous DecoratorNode on Backspace at the start of a top-level list#8676mayrang wants to merge 1 commit into
Conversation
…at the start of a top-level list (facebook#5072) When the first list item of a top-level list sat next to a block (or keyboard-selectable) decorator, Backspace at the start of that list item fell through deleteCharacter's merge-block path and removed the decorator outright. The bug also fires when two block decorators are adjacent and the user deletes the trailing list-item-side caret. Add a list-side KEY_BACKSPACE_COMMAND handler at COMMAND_PRIORITY_LOW that, when the caret is at the start of the first list item of a top-level list and the previous sibling is a non-isolated decorator that is either keyboard-selectable or a block, demotes that list item to a paragraph inserted before the list. The list item's content is preserved, the rest of the list keeps its items, and the decorator stays intact. Empty list items, nested lists, and the no-decorator case all return false, leaving the existing NodeSelection, outdent, and merge-block paths in place. Closes facebook#5072
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
potatowagon
left a comment
There was a problem hiding this comment.
Reviewed by Navi (Tater Thoughts Bobblehead) on behalf of @potatowagon.
Assessment: LGTM ✅
This is a well-crafted fix for #5072 — preventing Backspace from deleting a DecoratorNode adjacent to the first list item.
What I checked:
-
Logic correctness: The guard conditions in
$handleListItemBackspaceAdjacentToDecorator()are thorough and correct:- Collapsed selection at offset 0
- Anchor is in the first list item of a top-level list
- Previous sibling is a non-isolated DecoratorNode that is keyboard-selectable OR block-level
- Empty list items correctly deferred to existing core behavior
-
Edge cases: The condition
!(previousBlock.isKeyboardSelectable() || !previousBlock.isInline())correctly filters out inline non-selectable decorators while allowing block decorators AND inline keyboard-selectable decorators to trigger the fix. Nested lists properly fall through since their parent ListItemNode is not a DecoratorNode. -
Test coverage: Excellent — 421 lines covering bullet/number/check lists, inline decorators, single-item lists (list removal), two adjacent decorators, nested lists, isolated decorators, empty items, caret in middle, and caret on non-first items. Both the "handle" and "defer" paths are well-tested.
-
No regressions: The handler runs at
COMMAND_PRIORITY_LOWand returnsfalsefor all cases it doesn't handle, so existing Backspace behavior is unaffected. -
www backwards compatibility: No API surface changes — this is a module-private helper function. No exports added, removed, or renamed. Safe for all internal consumers (MLCComposer, XDSRichText, EPS, etc.).
CI status: Browser tests passing, CLA signed, Vercel deployed. Core unit/e2e/integrity still pending at time of review.
Ready to approve once CI completes green.
Description
When a top-level list sat next to a
DecoratorBlockNode(and the bug also fires for any non-isolated decorator that's keyboard-selectable or block-level), Backspace at the start of the first list item fell throughdeleteCharacter's merge-block path and removed the decorator outright. The same path also fires when two block decorators are adjacent and the user deletes the trailing list-item-side caret.@lexical/listnow registers aKEY_BACKSPACE_COMMANDhandler atCOMMAND_PRIORITY_LOW— sitting ahead oflexical-rich-text'sEDITOR-priority handler that callsdeleteCharacter. When the caret is at the start of the first list item of a top-level list and the previous sibling is a non-isolated decorator that is either keyboard-selectable or a block, the handler demotes that first list item to a paragraph inserted before the list. The list item's content is preserved, the rest of the list keeps its items, and the decorator stays intact. The issue author asked for "theListNodebe replaced by aParagraphNode" so the behavior matches other editors — that is what this PR does.Empty list items, nested lists, and the no-decorator case all return
false, leaving the existingNodeSelection, outdent, and merge-block paths in place. In particular, empty list items still hitLexicalSelection.ts's merge-next-block + decorator branch, which removes the empty element and places aNodeSelectionon the adjacent decorator.Closes #5072
Test plan
pnpm vitest run --project unit packages/lexical-list/— 110/110 pass. The newregisterListBackspaceDecorator.test.tscovers six positive cases (block decorator + bullet / number / check lists, inline keyboard-selectable decorator, single-item list removal, two adjacent block decorators) and six negative cases (no decorator before the list, caret on the second list item, caret in the middle of the first list item, nested list with no decorator before the inner list, isolated decorator, and the empty-first-list-item path that defers to core).pnpm vitest run --project unit— full unit suite 2946/2947 pass (1 pre-existing skip).pnpm devoflexical-playground(after). Verified on Chrome, Safari, Firefox.HorizontalRule+ bullet list "hello", caret at start, Backspace → HR preserved, "hello" sits as a paragraph between the HR and the rest of the list (or replaces the list outright if "hello" was the only item).PageBreak+ numbered list "hello" → same behavior.HorizontalRule+ bullet list with an empty first item, "second" below, caret on the empty first item, Backspace →NodeSelectionon the HR (core's existing branch), no paragraph demotion side effect; a second Backspace then removes the HR.outer+ Tab-indentedinner, caret at start ofinner, Backspace →inneroutdents to the outer level (unchanged from the existing path).