Skip to content

Lexical block with an array field causes the editor to remount and revert content after every save #16338

@lorand-kis

Description

@lorand-kis

Describe the Bug

When a Lexical block contains an array field whose items include a richText sub-field, saving the document causes the outer richText editor to remount and revert all content inside the block to its state at page load. A hard refresh restores the correct saved content.

This issue is particularly annoying when paired with auto-save since it will revert states causing editors to lose work.

Disclaimer: I also added a "Root Cause" and a "Workaround" section but these were provided by AI so take it with a grain of salt. I tested the workaround and it does indeed work.

Link to the code that reproduces this issue

https://github.com/lorand-kis/payload-issue-lexical-block-with-array-field

Reproduction Steps

Steps to reproduce

  1. Define a Lexical block with an array where each item contains a richText field alongside another array (or use the reproduction repo):
export const MyBlock: Block = {
  slug: 'my-block',
  fields: [
    {
      name: 'items',
      type: 'array',
      fields: [
        {
          name: 'body',
          type: 'richText',
          editor: lexicalEditor({
            features: ({ defaultFeatures }) => [...defaultFeatures],
          }),
        },
        {
          name: 'subitems',
          type: 'array',
          fields: [{ name: 'label', type: 'text' }],
        },
      ],
    },
  ],
}
  1. Open a document in the admin panel containing this block, with at least one row in items and subitems left empty for each row
  2. Edit the body richText field inside one of the rows
  3. Save the document
  4. The edited content reverts to what it was when the page first loaded

Expected behavior

The editor retains the saved content without remounting.

Actual behavior

The outer richText editor remounts after every save and all content inside affected blocks reverts to the page-load state.

Root cause

Three bugs interact to produce this:

  1. Structural mismatch between client and server Lexical state

On the client, removeEmptyArrayValues marks empty array fields with disableFormData: true. When the Lexical editor serializes the block node to JSON, those fields are omitted — so the saved state contains { body: "..." } with no subitems key. On the server, afterRead/promise.js:351 unconditionally normalizes absent array fields to []. The PATCH and getFormState responses therefore contain { body: "...", subitems: [] }.

  1. The structural mismatch triggers an unnecessary editor remount on every save

After every save, MERGE_SERVER_STATE updates the outer richText field with the server's Lexical state. handleInitialValueChange in Field.js compares the client state (no subitems key) against the server state (subitems: []) using dequal. The structural difference makes dequal return false, causing setRerenderProviderKey(new Date()) to fire and remount the entire outer Lexical editor.

  1. The remounted block initializer falls back to stale page-load data

On remount, the block component's useState lazy initializer runs. It looks up current field values in formData using a fieldName in formData check. Because nested fields are stored under dot-notation keys (e.g. items.0.body), this top-level check always fails. The initializer falls back to initialLexicalFormState — the form state captured at page load — and all block fields render with their original values.

Workaround

Add a collection-level afterRead hook that strips the empty array keys from the affected block nodes in the Lexical JSON after Payload's normalization runs, so the server response matches the client state and dequal passes.

Which area(s) are affected?

plugin: richtext-lexical

Environment Info

Binaries:
  Node: 22.22.0
  npm: 10.9.4
  Yarn: N/A
  pnpm: 10.28.2
Relevant Packages:
  payload: 3.82.1
Operating System:
  Platform: linux
  Arch: x64
  Version: #1 SMP PREEMPT_DYNAMIC Thu Jun  5 18:30:46 UTC 2025
  Available memory (MB): 31809
  Available CPU cores: 24

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions