Skip to content

[lexical-list] Bug Fix: Preserve previous DecoratorNode on Backspace at the start of a top-level list#8676

Open
mayrang wants to merge 1 commit into
facebook:mainfrom
mayrang:fix/5072-list-decorator
Open

[lexical-list] Bug Fix: Preserve previous DecoratorNode on Backspace at the start of a top-level list#8676
mayrang wants to merge 1 commit into
facebook:mainfrom
mayrang:fix/5072-list-decorator

Conversation

@mayrang

@mayrang mayrang commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

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 through deleteCharacter'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/list now registers a KEY_BACKSPACE_COMMAND handler at COMMAND_PRIORITY_LOW — sitting ahead of lexical-rich-text's EDITOR-priority handler that calls deleteCharacter. 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 "the ListNode be replaced by a ParagraphNode" 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 existing NodeSelection, outdent, and merge-block paths in place. In particular, empty list items still hit LexicalSelection.ts's merge-next-block + decorator branch, which removes the empty element and places a NodeSelection on the adjacent decorator.

Closes #5072

Test plan

  • pnpm vitest run --project unit packages/lexical-list/ — 110/110 pass. The new registerListBackspaceDecorator.test.ts covers 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).
  • tsc / flow / prettier / eslint clean.
  • Manual on Mac, comparing playground.lexical.dev (before) vs local pnpm dev of lexical-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 → NodeSelection on the HR (core's existing branch), no paragraph demotion side effect; a second Backspace then removes the HR.
    • Nested list: outer + Tab-indented inner, caret at start of inner, Backspace → inner outdents to the outer level (unchanged from the existing path).
    • Plain paragraph "intro" + bullet list "hello", caret at start of "hello", Backspace → merges into "intro" (existing merge-block path unchanged).
    • Two bullet items "first" / "second", caret at start of "second", Backspace → merges into "first" (existing merge-with-previous-item path unchanged).

…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
@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 7:55am
lexical-playground Ready Ready Preview, Comment Jun 11, 2026 7:55am

Request Review

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

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:

  1. 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
  2. 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.

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

  4. No regressions: The handler runs at COMMAND_PRIORITY_LOW and returns false for all cases it doesn't handle, so existing Backspace behavior is unaffected.

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

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: List: delete the leading ListItemNode will also delete the previous DecoratorBlockNode

2 participants