Skip to content

richtext-lexical: a newly inserted block takes two undo presses to remove (the initial form-state write is its own history entry) #18403

Description

@lexsemenenko

Describe the Bug

After inserting a block into a Lexical rich-text field that has BlocksFeature, the first Cmd/Ctrl+Z appears to do nothing: the block card is still there. A second Cmd/Ctrl+Z removes it. Expected: one undo removes the block that was just inserted, as it does for a paragraph, heading or list.

The same happens for a block created from a custom feature with editor.update(() => $insertNodes([$createBlockNode(…)])). We saw it on a text-style menu action that moves paragraphs into a new block: undo #1 changed nothing, and undo #2 restored the paragraphs.

Likely cause. In packages/richtext-lexical/src/features/blocks/client/component/index.tsx, the "Initial state for newly created blocks" effect (on main at 01f44da06, L245–L299; the same code path in v3.88.0 and v3.90.2) handles a block created on the client. It awaits getFormState, then writes the returned values back into the node:

editor.update(
  () => {
    const node = $getNodeByKey(nodeKey)
    if (node && $isBlockNode(node)) {
      const newData = newFormStateData
      newData.blockType = blockType
      node.setFields(newData, true)
    }
  },
  { tag: SKIP_DOM_SELECTION_TAG },
)

The update dirties the decorator node and carries no history tag. @lexical/history merges an update into the previous entry only when it is tagged history-merge, or when it is a same-type text change within the merge delay. So this write becomes its own entry, after the insertion's. Undo #1 reverts the write, which changes nothing visible because the node keeps its fields. Undo #2 reverts the insertion. componentInline/index.tsx has the same write.

Suggested fix. Merge the write into the insertion's history entry. It is not a user edit; it only fills in what the server resolved:

import { $getNodeByKey, HISTORY_MERGE_TAG, SKIP_DOM_SELECTION_TAG } from 'lexical'
// …
{ tag: [SKIP_DOM_SELECTION_TAG, HISTORY_MERGE_TAG] }

Apply the same in componentInline. One edge case: if the user edits between the insertion and the getFormState response, the write merges into that later entry instead. Undoing that edit would then also revert the defaults, which is still one step per user action.

Link to the code that reproduces this issue

https://github.com/lexsemenenko/payload-lexical-block-undo-repro

Reproduction Steps

The repo is npx create-payload-app@3.90.2 -t blank --db sqlite, plus one posts collection with this one field and nothing else changed:

{
  name: 'content',
  type: 'richText',
  editor: lexicalEditor({
    features: ({ defaultFeatures }) => [
      ...defaultFeatures,
      BlocksFeature({ blocks: [{ slug: 'note', fields: [{ name: 'text', type: 'text' }] }] }),
    ],
  }),
}
  1. pnpm install, then cp .env.example .env (SQLite; the database file is created inside the repo), then pnpm dev (port 3190).
  2. Open http://localhost:3190/admin and create the first user.
  3. Posts → Create New. Click into Content, type Hello, press Enter.
  4. Type /note and press Enter. A "Note" block card appears.
  5. Wait about two seconds, then press Cmd/Ctrl+Z once. The card is still there.
  6. Press Cmd/Ctrl+Z again. The card is removed.

A Playwright spec (tests/undo-block.spec.ts) automates this. expected: ONE undo removes a just-inserted block fails on 3.90.2 (Expected: 0, Received: 1). The companion test records the card count after each undo: 1, then 0. The output of one run is in repro-run.txt.

Which area(s) are affected?

plugin: richtext-lexical

Environment Info

payload: 3.90.2
@payloadcms/richtext-lexical: 3.90.2
@payloadcms/next: 3.90.2
@payloadcms/ui: 3.90.2
@payloadcms/db-sqlite: 3.90.2
next: 16.3.3
react / react-dom: 19.2.6
lexical / @lexical/history / @lexical/react: 0.50.0
Node: 24.14.1
pnpm: 10.33.2
Browser: Chromium (via @playwright/test 1.58.2); also seen by hand in Chrome
First observed on payload 3.88.0 / lexical 0.41.0 / next 16.2.12; the code path is unchanged on main at 01f44da06.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions