payloadcms / payloadcms/payload
Lexical block silently loses array-field rows on remount (data loss)
Nobody has claimed this yet.
- 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 cause — features/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:
cachedFormStateis flat —images,images.0.image,images.1.image, … (@payloadcms/uiforms/fieldSchemasToFormState/addFieldStatePromise.js)formDatais nested —{ images: [{ image }, { image }] }(theBlocksNodefields)
So 'images.0.image' in formData is always false. Consequences:
- No row leaf is ever refreshed from
formData— everyimages.N.imagekeeps its cached value. - No row leaf is ever added, so rows created since the RSC render do not exist in the merged state.
images.valueis matched ('images' in formDatais 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:
Formreplaces its entire state with it —@payloadcms/ui/forms/Form/index.js:638-649(REPLACE_STATE).- The next
onChangesends thatformStateto the server;buildFormState.js:109derivesdatafrom it (const data = incomingData || reduceFieldsToValues(formState, true)), so the server faithfully returns the truncated rows. onChangewrites them onto the node vianode.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:
- Register a block with a
type: 'array'field (in our caseimages,required: true,minRows: 1, each row anuploadrelation) inBlocksFeatureon arichTextfield. - Insert the block into a document body and add several array rows.
- 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. - 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
uploadsub-fields —fieldName in formDatais false for any dotted path, sogroup,blocksandtabssub-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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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