feat(richtext-lexical): unify lexical nested fields with document form state#15683
Draft
feat(richtext-lexical): unify lexical nested fields with document form state#15683
Conversation
Contributor
📦 esbuild Bundle Analysis for payloadThis analysis was generated by esbuild-bundle-analyzer. 🤖
Largest pathsThese visualization shows top 20 largest paths in the bundle.Meta file: packages/next/meta_index.json, Out file: esbuild/index.js
Meta file: packages/payload/meta_index.json, Out file: esbuild/index.js
Meta file: packages/payload/meta_shared.json, Out file: esbuild/exports/shared.js
Meta file: packages/richtext-lexical/meta_client.json, Out file: esbuild/exports/client_optimized/index.js
Meta file: packages/ui/meta_client.json, Out file: esbuild/exports/client_optimized/index.js
Meta file: packages/ui/meta_shared.json, Out file: esbuild/exports/shared_optimized/index.js
DetailsNext to the size is how much the size has increased or decreased compared with the base branch of this PR.
|
…te-parent # Please enter a commit message to explain why this merge is necessary, # especially if it merges an updated upstream into a topic branch. # # Lines starting with '#' will be ignored, and an empty message aborts # the commit.
…at/lexical-form-state-parent
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #10942
Before
Lexical nodes with nested fields (blocks, inline blocks, links, uploads) were fully isolated from the parent document's form system. Each node type managed its own form lifecycle: block and inline block components rendered their own
<Form>component directly on the page, with their owninitialStatebuilt via a dedicatedbuildInitialStateutility, their own state reducer, and their ownonChangehooks to sync field changes back into the Lexical node viaeditor.update(). Links and uploads did the same when their editing drawers opened.This meant every node with sub-fields was effectively a mini-document with its own form context and submission flow that we had to handle manually - completely separate from the parent document form. From the perspective of Payload's form system, these fields didn't exist:
useAllFormFields(),useFormFields(), and other form hooks couldn't see them. They were buried inside the rich text field's serialized JSON value.After
Nested Lexical fields now live in the parent document's form state, just like array fields, standard blocks, and top-level fields. A lexical block's text field sits at
lexicalField.{nodeId}.fieldNamein the same flatFormStatemap. The node ID is the bridge between the freeform Lexical JSON tree and the flat form state - it's the one stable identifier every node with sub-fields already has. Editing drawers read from and write to the parent form state directly. Creation drawers (where no node exists yet) still use an isolated<Form>with their own server-side form state initialization.Why
Consistency and simplicity. Nested Lexical fields are Payload fields - they're defined with the same field config, validated the same way, stored the same way. There was no reason for them to be invisible to the rest of the form system. This change makes them first-class fields, which means any component, plugin, or custom hook can read and write Lexical sub-fields using standard form hooks. It also simplifies the client-side architecture: node components no longer need to manage their own state lifecycle.
How
Server: Form state population
A new
buildFormStatemethod on theRichTextAdapterinterface lets the Lexical adapter hook into the existing form state pipeline. During server-side form state construction, the adapter walks the serialized Lexical tree, finds nodes with sub-fields via feature-providedgetSubFieldsDataandgetSchemaPathfunctions, and calls the standarditerateFieldsutility to populate form state entries under{richTextPath}.{nodeId}.*. This works the same waygenerateSchemaMapalready works for the schema map.Server: Persistence
On save, a
beforeChangehook merges flat form state values back into the Lexical JSON tree before persistence.Client: Node components
Node components no longer receive full field data objects from
decorate(). They receive only structural identifiers (blockType,id,nodeKey) and read user-editable field data from the parent form state viauseFormFields.Client: Drawers
Drawers use two rendering modes based on whether a node already exists:
nodeIdpresent):InlineDrawerContentrenders fields inline within the parent document form. TheparentPathis set to{richTextFieldPath}.{nodeId}, so fields read from and write to the parent form state directly.RenderLexicalFieldswraps the fields to sync changes back to the Lexical node.nodeId):StandaloneDrawerContentrenders its own<Form>with server-side form state initialization. On submit, the data is used to create the new Lexical node.Client: Bidirectional field sync
Since field data now has two homes - the flat form state (source of truth for the UI) and the Lexical node tree (source of truth for serialization) - changes in either direction need to be propagated to the other.
Form → node (
RenderLexicalFields): WrapsRenderFieldswith aFieldChangeNotifierProvider. When any child field callsuseField().setValue(), the notifier fires with the field's path and new value. Changes are batched viarequestAnimationFrameand flushed to the Lexical node vianode.setSubFieldValue()insideeditor.update().Node → form (
NodeFieldsSyncPlugin): An always-loaded Lexical plugin that handles the reverse direction. When code programmatically callsnode.setFields()ornode.setSubFieldValue()(e.g., a toolbar button modifying an inline block's data), the plugin detects the change viaregisterUpdateListener, compares the node's fields against the current form state, and pushes any differences into form state viadispatchFields.No explicit cycle prevention is needed between the two:
NodeFieldsSyncPlugincallsdispatchFields(React reducer dispatch) directly, which never triggersFieldChangeNotifier- that callback only fires fromuseField().setValue(). In the other direction, by the timeRenderLexicalFieldsflushes to the node, the form state already holds the matching value, so the plugin's comparison finds no diff and skips.Todo
buildFormStatemethod on theRichTextAdapter, and the others, serve as extensions of the built-in payload buildFormState handling. We should change these to just one generic method that returns the fields and the data (same object reference), and then have Payload handle all the recursion