feat(richtext-lexical)!: type-safe lexical schemas and generated types#16782
Conversation
…ame outputSchema to jsonSchema
…-types # Conflicts: # docs/fields/array.mdx # docs/fields/blocks.mdx # docs/fields/checkbox.mdx # docs/fields/code.mdx # docs/fields/date.mdx # docs/fields/email.mdx # docs/fields/group.mdx # docs/fields/join.mdx # docs/fields/json.mdx # docs/fields/number.mdx # docs/fields/point.mdx # docs/fields/radio.mdx # docs/fields/relationship.mdx # docs/fields/rich-text.mdx # docs/fields/select.mdx # docs/fields/text.mdx # docs/fields/textarea.mdx # docs/fields/upload.mdx # docs/migration-guide/v4.mdx # packages/payload/src/admin/RichText.ts # packages/richtext-lexical/src/features/blockquote/server/index.ts # packages/richtext-lexical/src/features/blocks/client/component/index.tsx # packages/richtext-lexical/src/features/blocks/client/componentInline/index.tsx # packages/richtext-lexical/src/features/blocks/client/nodes/BlocksNode.tsx # packages/richtext-lexical/src/features/blocks/client/nodes/InlineBlocksNode.tsx # packages/richtext-lexical/src/features/blocks/server/index.ts # packages/richtext-lexical/src/features/blocks/server/nodes/BlocksNode.tsx # packages/richtext-lexical/src/features/blocks/server/nodes/InlineBlocksNode.tsx # packages/richtext-lexical/src/features/converters/lexicalToHtml/async/converters/upload.ts # packages/richtext-lexical/src/features/converters/lexicalToHtml/sync/converters/upload.ts # packages/richtext-lexical/src/features/converters/lexicalToJSX/converter/converters/upload.tsx # packages/richtext-lexical/src/features/experimental_table/server/index.ts # packages/richtext-lexical/src/features/heading/server/index.ts # packages/richtext-lexical/src/features/horizontalRule/server/nodes/HorizontalRuleNode.tsx # packages/richtext-lexical/src/features/link/nodes/types.ts # packages/richtext-lexical/src/features/lists/plugin/index.tsx # packages/richtext-lexical/src/features/relationship/server/nodes/RelationshipNode.tsx # packages/richtext-lexical/src/features/typesServer.ts # packages/richtext-lexical/src/features/upload/server/nodes/UploadNode.tsx # packages/richtext-lexical/src/field/Diff/converters/upload/index.tsx # packages/richtext-lexical/src/lexical/config/server/sanitize.ts # packages/richtext-lexical/src/types/nodeTypes.ts # packages/richtext-lexical/src/types/schema.ts
📦 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.
|
…-types # Conflicts: # docs/migration-guide/v4.mdx # packages/payload/src/fields/config/types.ts # packages/payload/src/utilities/configToJSONSchema.ts
GermanJablo
left a comment
There was a problem hiding this comment.
1. Custom link fields can collide between editors
The generated Lexical node type name is based on a hash of nodeSchemas:
packages/richtext-lexical/src/types/schema.ts#L78-L80
For link fields with custom fields, the custom field schema is stored separately under a name derived from that same hash:
packages/richtext-lexical/src/features/link/server/schema.ts#L148-L151
The problem is that the hash is computed before those separate LexicalLinkFields_<hash> definitions are part of the hashed value. So two editors can have the same enabled Lexical nodes but different custom LinkFeature fields, and both can end up using the same LexicalNodes_* / LexicalLinkFields_* names.
Example:
// editor A
LinkFeature({
fields: [{ name: 'label', type: 'text' }],
})
// editor B
LinkFeature({
fields: [{ name: 'trackingId', type: 'text' }],
})If everything else about the editor is the same, one generated LexicalLinkFields_* definition can overwrite the other. That would make one rich text field use the wrong link field type.
I think the link field schema, or some stable hash of it, needs to be included in the node union hash.
2. Duplicate explicit block interfaceName values can overwrite each other
registerBlockInterface handles collisions for auto-generated block interface names, but explicit interfaceName values skip that path:
packages/payload/src/utilities/configToJSONSchema.ts#L1361-L1364
The collision handling only runs after that early return:
packages/payload/src/utilities/configToJSONSchema.ts#L1374-L1379
So if two different blocks both set interfaceName: 'Hero', the second one replaces the first one in interfaceNameDefinitions. Any field that referenced the first one can now point at the second block's shape.
I think this should either throw a clear error for duplicate explicit names, or use the same hash fallback that auto-generated names use.
3. The migration guide says missing jsonSchema falls back to unknown, but the code omits those nodes
The migration guide says:
docs/migration-guide/v4.mdx#L536
But the code filters out any node that does not have jsonSchema:
packages/richtext-lexical/src/types/schema.ts#L68-L70
That means a custom node without jsonSchema is not typed as { [k: string]: unknown }. It is missing from the generated node union entirely.
That is important for third-party Lexical features. Their data can still exist at runtime, but generated TypeScript will not include those nodes. The docs should match the behavior, or the implementation should add the loose fallback described by the docs.
4. MCP schema simplification may loosen Lexical node unions
Lexical node unions are built as oneOf:
packages/richtext-lexical/src/types/schema.ts#L78
The MCP simplifier now walks definitions:
packages/plugin-mcp/src/utils/schemaConversion/simplifyRelationshipFields.ts#L66-L75
When it sees a oneOf with no $ref, it changes it into anyOf:
packages/plugin-mcp/src/utils/schemaConversion/simplifyRelationshipFields.ts#L47-L50
That can change the meaning of LexicalNodes_* inside MCP input schemas. These are discriminated node unions, so oneOf is stricter than anyOf. I would add a test with a collection that has a Lexical rich text field exposed through MCP, and assert the Lexical node union stays strict while relationship values are still simplified to IDs.
5. Upload extra fields are strict in JSON Schema but loose in generated TypeScript
The upload node TypeScript template always emits loose upload fields:
packages/richtext-lexical/src/features/upload/server/schema.ts#L53-L59
But the JSON Schema builder does know the configured upload extra fields:
packages/richtext-lexical/src/features/upload/server/schema.ts#L95-L120
So UploadFeature({ collections: { uploads: { fields: [...] } } }) can validate those fields strictly, but the generated TypeScript still exposes them as { [k: string]: unknown }.
If this PR's goal is accurate generated Lexical types, I think upload extra fields should either be represented in SerializedUploadNode generics, or this limitation should be called out clearly.
Suggested tests
- Two rich text fields with the same nodes but different custom
LinkFeaturefields should generate different link field types. - Two different blocks with the same explicit
interfaceNameshould not silently overwrite each other. - A custom Lexical node without
jsonSchemashould either appear asunknownin the union, or docs should say it is omitted. - MCP create/update schema for a collection with Lexical rich text should keep the Lexical node union strict.
- Upload feature custom fields should either be typed in generated TypeScript, or there should be a test showing they intentionally remain loose.
…erate the same, conflicting generated type
…flicting type, overriding each other. Now, they are subject to the same hashing mechanism as auto-generated ones
…typed, internal and generated types typed them as { [k: string]: unknown }
…uces 40% bigger json schema and drops descriptions
GermanJablo
left a comment
There was a problem hiding this comment.
Thanks for working through the review points. Just two more small observations:
1. Upload extra fields are better, but I think one case is still loose
The upload field typing is definitely better than before. SerializedUploadNode now has a second generic for fields, and custom upload fields now get their own generated interface. So the old { [k: string]: unknown } issue is improved.
The part I am still unsure about is how the generated type combines all upload slugs with all upload field types. The current tsType builds one slug union and one fields union in the upload schema generator.
That means TypeScript may know "this upload node can use these upload collections" and "this upload node can use these field shapes", but it may not know which field shape belongs to which relationTo.
For example, imagine this config:
UploadFeature({
collections: {
uploads: {
fields: [{ name: 'caption', type: 'text', required: true }],
},
uploads2: {
fields: [{ name: 'credit', type: 'text', required: true }],
},
},
})Ideally, TypeScript would understand this:
{ relationTo: 'uploads', fields: { caption: string } }
{ relationTo: 'uploads2', fields: { credit: string } }But the current shape looks closer to this:
SerializedUploadNode<'uploads' | 'uploads2', UploadsFields | Uploads2Fields>That means the slug and the fields can drift apart. For example, relationTo: 'uploads' could accidentally be paired with the fields intended for uploads2.
The current lexical test config has a smaller version of this: only uploads has the custom caption field, but the generated node type applies that custom fields interface to both uploads and uploads2 in the generated lexical types. The generated caption interface is here.
Ideally, the codegen would be able to differentiate this.
2. I think we need better type tests around the APIs users call
The fact that the existing test folder did not suddenly show a lot of new TypeScript errors might mean we do not have enough type-level tests around the main APIs where users pass data.
So I think it would be useful to add focused tstyche tests for the APIs that now enforce the stricter generated rich text shape.
For example:
payload.createaccepts a validrichTextvalue generated withbuildEditorState<Post['richText']>().payload.updateaccepts a valid generated rich text value.payload.updaterejects an invalid lexical object, like a node with an unknowntype, a block node missing requiredfields, or an upload node whosefieldsdo not match its upload collection.payload.updateGlobalhas the same coverage for a global rich text field.- The SDK
createandupdateAPIs enforce the same shape. - Converter functions still accept rich text data returned from Payload.
The important part is that some of these tests should fail before this PR and pass after it. That would show that the new generated types are not only being generated, but are also enforced where users will actually feel the change.
Without tests like that, I think we are mostly proving that the generated types exist. We are not proving enough that payload.update, payload.create, and the SDK are protected in real usage.
I thought it would be useful to include the generated test payload types in this PR so we can see the full impact of the rich text type-generation changes. ## Summary - Regenerated payload types across the test suites with `pnpm dev:generate-types`. - Includes the generated `test/uuid-v7/payload-types.ts` file that the script now produces. ## Test plan - Ran `pnpm dev:generate-types`.
This PR makes
richTextfields properly typed inpayload-types.ts. Until now, everyrichTextfield was typed as a vague{ [k: string]: unknown }blob. TypeScript couldn't help you do anything with rich text content, and you had to manually type data you get from payload using ourTypedEditorStatehelpers.Now, payload generates fully-typed editor state based on the nodes enabled in a given richText editor. In order to deduplicate as many types as possible, nodes generate their own individual, shared interfaces (
SerializedTextNode,SerializedHeadingNode,SerializedBlockNode) that can be customized using generics. The richText field is typed as a union of all the nodes its editor uses.What the generated types look like
Before.
After.
The union name is a hash of its own contents, so two fields with the same set of nodes share one alias instead of duplicating.
Relationship nodes only list non-upload collections - upload-enabled collections show up under
SerializedUploadNodeinstead, so they don't appear in the relationship union.What this means for your app
The stored data shape hasn't changed, so there's nothing to migrate. You regenerate
payload-types.tsand your data still parses.The types are stricter now, though, so TypeScript can start flagging rich text code that used to slip through:
node.children) without first narrowing onnode.typewill error. Narrow bytypeand you get real autocomplete.{ [k: string]: unknown }shape is gone.TypedEditorState/DefaultTypedEditorStateare stricterThese are the helpers you use to hand-type rich text (converters, custom renderers, and so on).
Element nodes are now generic over their
children, and you pass the node union into them yourself. Before,TypedEditorStaterewrote each node'schildreninto the recursive union for you (an internalRecursiveNodeshelper, capped at a fixed depth). NowTypedEditorState<T>usesTas-is, so a node'schildrencome straight from the generic you give it:import type { SerializedParagraphNode, SerializedTextNode, TypedEditorState, } from '@payloadcms/richtext-lexical' - type MyNodes = SerializedParagraphNode | SerializedTextNode + type MyNodes = SerializedParagraphNode<MyNodes> | SerializedTextNode function renderRichText(state: TypedEditorState<MyNodes>) { // ... }SerializedParagraphNodeis an element node, so it takes the union (<MyNodes>) to type its children.SerializedTextNodeis a leaf with no children, so it stays bare.For the common case - the built-in nodes plus a few of your own -
DefaultNodeTypesOfdoes the threading for you, andDefaultTypedEditorStatewith only built-in nodes needs no change:Why the generic? Rich text is a tree, so a node's children are nodes from the same union - but that union is built out of the nodes, so it's circular and a node can't just name "the union it belongs to". Making each node generic over its children and having the union pass itself breaks the cycle and types the tree at any depth. The old
RecursiveNodeshelper expanded children a fixed number of levels and then gave up; this has no depth limit.Breaking changes (custom adapters / features / type-gen scripts)
The rest only matters if you wrote a custom rich-text adapter, your own type-generation script, or a custom lexical feature.
configToJSONSchemareturns an object nowIt used to return a
JSONSchema4. It now returns{ jsonSchema, typeStringDefinitions }.fieldsToJSONSchematakes one object instead of 6 positional argsentityToJSONSchemagot a new required argumenttypeStringDefinitionsis now a required positional argument at position 5. The oldoptsobject becomes an optionalforceInlineBlocks?: booleanat the end.entityToJSONSchema( config, entity, interfaceNameDefinitions, defaultIDType, + typeStringDefinitions, collectionIDFieldTypes, i18n, - { forceInlineBlocks: true }, + true, )Custom lexical features:
generatedTypes.modifyJSONSchemais goneFeatures used to contribute types by mutating the whole field schema after the fact, through
generatedTypes.modifyJSONSchema(and the sanitizedmodifyJSONSchemasarray). That's removed. Each node now contributes its own schema through ajsonSchemafunction oncreateNode, and the editor stitches them into the union for you (see How features contribute types).export const MyFeature = createServerFeature({ feature: () => ({ - generatedTypes: { - modifyJSONSchema: ({ currentSchema, interfaceNameDefinitions }) => currentSchema, - }, nodes: [ createNode({ node: MyNode, + jsonSchema: ({ elementNodeSchema, nodeUnionName, typeStringDefinitions }) => { + typeStringDefinitions.add(`export interface SerializedMyNode<TChildren> { /* ... */ }`) + return elementNodeSchema({ nodeType: 'my', tsType: `SerializedMyNode<${nodeUnionName}>` }) + }, }), ], }), })A node without a
jsonSchemafunction falls back to{ [k: string]: unknown }, so leaving it off is fine - that node just stays loosely typed.Lexical: registering the same node twice now throws
sanitizeServerFeaturesrejects two features registering the same node type. Before, it silently kept the last one.How features contribute types
Each feature attaches a
jsonSchemafunction to its node viacreateNode. The function gets a helper for the shared element shape and aSet<string>it can dump raw TS source into:The same TS source string from many nodes only lands in the output once -
Set<string>deduplicates for free. Nodes withoutjsonSchemastay as{ [k: string]: unknown }, so features can opt in node by node.Internal refactor changes
types/builtInNodes.ts(SerializedLexicalElementBase,LexicalElementFormat,LexicalRichText, …). Per-node helpers live next to their schemas underfeatures/*/server/schema.ts.nodeTypes.tsre-exports from the new locations.payloadnow exportsentityToStandaloneJSONSchema, which builds a self-contained schema for a single collection/global (the entity plus only the definitions it uses) instead of slicing the whole-config schema.