payloadcms / payloadcms/payload
Lexical blocks: selecting an upload wipes other field values in the same block (Form re-initializes from stale initialState)
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
In a Lexical BlocksFeature block containing two or more upload fields, selecting a document in one upload field silently erases the other upload field values in that block. The loss is persisted: the next form-state round-trip writes the impoverished state back into the editor node, so the data is gone from the document, not just the UI.
This is admin-side data loss with no error shown. Content editors experience it as "the CMS randomly deleted my file."
Reproduced on 3.55.1, 3.61.1, and 3.85.2 (latest at time of filing). It is not fixed by #14295 (see analysis below — the bug is in the exact code #14295 touched). Blocks with a single upload field mask the bug (there is no sibling value to lose), which may be why this hasn't been reported despite being long-standing.
Notes from bisecting the trigger space:
- Reproduces with bare upload fields — no
filterOptions, noadmin.condition, nogroupnesting required (though grouped fields are additionally affected, see analysis). - Reproduces with autosave disabled, so it is not an autosave race.
- Only Lexical blocks are affected. The same block used in a page-builder
blocksfield is fine. - Manually saving the document between selections "heals" the already-set value (because the server-rendered block form-state cache is rebuilt from the saved doc) — values set since the last save are the ones that get wiped.
Link to the code that reproduces this issue
Minimal reproduction is config-only (verified in a standard Payload 3 + Next 15 app; happy to spin up a fresh create-payload-app repro repo on request):
// blocks/UploadTestBlock.ts
import type { Block } from 'payload'
export const UploadTestBlock: Block = {
slug: 'upload-test-block',
interfaceName: 'UploadTestBlock',
fields: [
{ name: 'uploadA', type: 'upload', relationTo: 'media' },
{ name: 'uploadB', type: 'upload', relationTo: 'media' },
],
}
// any collection with a richText field
{
name: 'content',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures, rootFeatures }) => [
...defaultFeatures,
...rootFeatures,
BlocksFeature({ blocks: [UploadTestBlock] }),
],
}),
}
Reproduction Steps
- Create a doc, add the
upload-test-blockto the rich text field. - Set uploadA via the list drawer. It displays correctly.
- Wait a moment (no save), set uploadB via the list drawer.
- uploadA is now cleared — in the form, in the lexical node JSON, and (after autosave/save) in the document.
- Symmetric in the other order; with four upload fields, selecting any one clears the other three.
Which area(s) are affected?
richtext-lexical, ui
Environment Info
payload: 3.55.1 / 3.61.1 / 3.85.2 (all reproduce)
next: 15.5.9
react / react-dom: 19.0.0
db: @payloadcms/db-mongodb
node: 25.x, pnpm 10.10.0
browser: Chrome (also reproduced in Safari + incognito)
Root-cause analysis (instrumented traces)
We instrumented BlockComponent (packages/richtext-lexical/src/features/blocks/client/component/index.tsx) with event beacons and captured the full lifecycle around a reproduction. Three findings:
1. Opening any drawer recycles the block's effects while preserving component state; the block Form then re-initializes from a stale initialState.
On 3.61.1, when the upload list drawer opens/closes, the block component's []-dep effects run their cleanups and re-run, but useState initializers do not re-run — i.e. the subtree is hidden/re-shown with state preserved (Activity/Offscreen-style), not remounted. On re-show, the block's Form re-initializes its field state from the initialState React state, which is still the snapshot captured when the block was created (for a new block: essentially { id } from the initial client-side getFormState fetch). Every value adopted since — e.g. uploadA — drops out of the form state at that moment.
Trace excerpt (3.61.1, probes on onChange entry/exit and mount/unmount; note uploadA present in the node's formData at re-mount, then absent from the form state the next onChange sends):
onChange:prev {"uploadA":"6939dcb79...","id":"6a4d7703...","blockName":""} ← pick A
onChange:resp {"uploadA":"6939dcb79...","id":"6a4d7703..."} ← adopted, node updated
UNMOUNT / MOUNT formData={"id":"6a4d7703...","uploadA":"6939dcb79...", ...} ← drawer for B; node still has A
onChange:prev {"uploadB":"6939dc5c2...","id":"6a4d7703...","blockName":""} ← pick B — uploadA GONE, blockName resurrected
onChange:resp {"uploadB":"6939dc5c2...","id":"6a4d7703..."} ← server echoes; setFields persists the loss
The resurrection of blockName: "" alongside the disappearance of uploadA is the fingerprint of re-initialization from the creation-time initialState.
On 3.85.2 the component hard-remounts instead (state destroyed), but the outcome is identical because of finding 2.
2. The #14295 merge only overlays keys that already exist in the cached form state — it never adds keys.
The remount path re-initializes from initialLexicalFormState?.[formData.id]?.formState merged with formData:
Object.fromEntries(Object.entries(cachedFormState).map(([fieldName, fieldState]) =>
[fieldName, fieldName in formData ? { ...fieldState, initialValue: formData[fieldName], value: formData[fieldName] } : fieldState]))
It iterates cachedFormState entries only. A field that has a value on the node but no entry in the cached state (because the cache was computed before the field was ever set — the server omits valueless fields from built state) is silently dropped. So the "merge current formData values so we don't lose user edits" fix cannot preserve exactly the edits at risk.
3. Nested (group) fields can never match the merge condition.
fieldName in formData compares flat form-state paths (videoSource.poster) against nested formData ({ videoSource: { poster } }) — always false — so grouped fields don't benefit from the merge even when their keys are cached.
The server is not involved. We invoked buildFormState directly with a form state containing both upload values: both come back intact. The wipe happens purely in the client form-state lifecycle; the server round-trip merely persists whatever it is sent.
Fix approach we verified locally
Two changes to BlockComponent, currently shipped as a pnpm patch in our project, verified to fully resolve the issue:
- Track the latest adopted form state in a ref (updated in the
onChangeadopt path and the initial client-side fetch), and sync it intoinitialStatein the[]-effect cleanup (i.e. before the subtree is hidden). Re-initialization then uses current values. This alone fixes the 3.61.1 hide/show path. - For real remounts: distrust the cached form state when it is missing (or disagrees with) values present in the node's
formData— walkformData(including nested groups / array rows, comparing flat dot-paths) and, on mismatch, fall through to the existing "new block" client-sidegetFormStaterefetch, which rebuilds complete state from the node data.
Possibly related: #14295 (insufficient for the reasons above), #15683 (unified lexical/document form state would presumably remove this class of bug).
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 with packages/richtext-lexical/src/features/blocks/client/component/index.tsx and reproduce the two-upload configuration in the issue using a Lexical BlocksFeature block. Trace BlockComponent form-state initialization through drawer close and upload selection, then verify that existing sibling upload values remain in the node JSON and document after subsequent changes and saves.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- nextjs, react, typescript
- Domain
- frontend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 48/100