payloadcms / payloadcms/payload

Lexical block silently loses array-field rows on remount (data loss)

Open
#17,894 0 comments 1 reaction 0 assignees View on GitHub

Nobody has claimed this yet.

`area: ui` `plugin: richtext-lexical`
Dominant language
TypeScript
Stars
44.8k
Forks
4.2k
Avg merge
2d 21h
Merged PRs (30d)
53

Description

Describe the Bug

When a block registered via BlocksFeature contains an array field, remounting the block component silently reverts that array to a stale server-rendered snapshot. Rows the editor added since the last RSC render are dropped, the truncated value is written back onto the Lexical node, and autosave persists it. No error, no warning, clean 200.

The same block used as a top-level type: 'blocks' field is unaffected — it never goes through this code path.

Root causefeatures/blocks/client/component/index.js:72-91, the initialState useState initializer:

const mergedState = Object.fromEntries(
  Object.entries(cachedFormState).map(([fieldName, fieldState]) => [
    fieldName,
    fieldName in formData
      ? { ...fieldState, initialValue: formData[fieldName], value: formData[fieldName] }
      : fieldState,
  ]),
)

Its own comment states the intent: "Merge current formData values into the cached form state. This ensures that when the component remounts (e.g., due to view changes), we don't lose user edits."

The two inputs have different shapes:

  • cachedFormState is flatimages, images.0.image, images.1.image, … (@payloadcms/ui forms/fieldSchemasToFormState/addFieldStatePromise.js)
  • formData is nested{ images: [{ image }, { image }] } (the BlocksNode fields)

So 'images.0.image' in formData is always false. Consequences:

  1. No row leaf is ever refreshed from formData — every images.N.image keeps its cached value.
  2. No row leaf is ever added, so rows created since the RSC render do not exist in the merged state.
  3. images.value is matched ('images' in formData is true) and is set to the whole array — but the form-state contract requires the row count, a number (addFieldStatePromise.js:254-256, forms/Form/fieldReducer.js:272).

From there the truncated state propagates:

  • Form replaces its entire state with it — @payloadcms/ui/forms/Form/index.js:638-649 (REPLACE_STATE).
  • The next onChange sends that formState to the server; buildFormState.js:109 derives data from it (const data = incomingData || reduceFieldsToValues(formState, true)), so the server faithfully returns the truncated rows.
  • onChange writes them onto the node via node.setFields(newData, true) — a wholesale replace (features/blocks/server/nodes/BlocksNode.js:83-89: writable.__fields = fields).

When the cached snapshot has zero rows, removeEmptyArrayValues + reduceFieldsToValues drop the key entirely (component/index.js:267), so the node ends up with no images key at all.

I initially suspected a stale documentFormState was the cause and ruled that out: holding formState at 3 rows and varying only documentFormState (fresh / 1 row / 0 rows / absent) returns all 3 rows in every case. buildFormState:109 means the document copy never gets a vote.

Link to the code that reproduces this issue

https://gist.github.com/TrimiB/835ce5089993cbfd73307a6836341c25

This is a verbatim transcription of the merge above with no Payload dependency, so it runs in about a second (node repro-lexical-array-remount.mjs). I appreciate this is not a create-payload-app repo — happy to build one if that is needed for triage, but the defect is a pure function of the two input shapes and is visible directly from the shipped source.

cached (RSC snapshot) x node formData   ->   merged state after remount

cached= 3img  node= 3img  ->  rows=3  image-leaves=3  images.value=Array(3)  <-- must be a number
cached= 1img  node= 3img  ->  rows=1  image-leaves=1  images.value=Array(3)     *** 2 IMAGE(S) LOST ***
cached= 0img  node= 3img  ->  rows=0  image-leaves=0  images.value=Array(3)     *** 3 IMAGE(S) LOST ***
cached= 4img  node=15img  ->  rows=4  image-leaves=4  images.value=Array(15)    *** 11 IMAGE(S) LOST ***
Reproduction Steps

In a real app:

  1. Register a block with a type: 'array' field (in our case images, required: true, minRows: 1, each row an upload relation) in BlocksFeature on a richText field.
  2. Insert the block into a document body and add several array rows.
  3. Cause the block component to remount. React StrictMode does this in dev; a view change or an RSC refresh does it in any mode — an open live-preview pane calling router.refresh() on every autosave makes it frequent.
  4. The array reverts to whatever the last server render contained, and autosave persists it.

Observed in our database across consecutive saved versions of one post: images[15]images[4], and images[1] → no images key 2.3s later. A second block with an array field (scores) is damaged in the same document set. Zero occurrences for the same blocks used as top-level blocks fields on two other collections.

For the standalone script: node repro-lexical-array-remount.mjs — output above.

Which area(s) are affected?

plugin: richtext-lexical, area: ui

Environment Info
Payload: 3.85.1
@payloadcms/richtext-lexical: 3.85.1
@payloadcms/ui: 3.85.1
@payloadcms/next: 3.85.1
@payloadcms/db-mongodb: 3.85.1
Next.js: 16.2.6
React: 19.2.6  /  react-dom: 19.2.6
Node: 22.22.3
Platform: darwin arm64 (Darwin 25.6.0)
Notes
  • Not specific to upload sub-fields — fieldName in formData is false for any dotted path, so group, blocks and tabs sub-fields inside a Lexical block have the same exposure. Only top-level scalar fields are refreshed by this merge.
  • Not reproduced in a production build in our app, consistent with StrictMode's dev-only remount being the common trigger; the defect itself is mode-independent.
Suggested fix

Flatten formData before testing membership (or reduce the cached state to values, merge against the nested data, and rebuild the flat state), so that dotted leaf paths resolve against nested data, rows added since the snapshot are represented, and images.value stays a row count.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in features/blocks/client/component/index.js:72-91 and run node repro-lexical-array-remount.mjs to reproduce the flat-state and nested-data mismatch. Read addFieldStatePromise.js, forms/Form/fieldReducer.js, and buildFormState.js to trace array row counts and values. Done means remounting preserves newly added nested rows and keeps the array field state contract intact.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript, nextjs, react
Domain
frontend
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
75/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.