Skip to content

feat(richtext-lexical)!: type-safe lexical schemas and generated types#16782

Merged
AlessioGr merged 68 commits into
mainfrom
feat/accurate-lexical-types
Jun 5, 2026
Merged

feat(richtext-lexical)!: type-safe lexical schemas and generated types#16782
AlessioGr merged 68 commits into
mainfrom
feat/accurate-lexical-types

Conversation

@AlessioGr
Copy link
Copy Markdown
Member

@AlessioGr AlessioGr commented May 28, 2026

This PR makes richText fields properly typed in payload-types.ts. Until now, every richText field 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 our TypedEditorState helpers.

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.

content?: {
  [k: string]: unknown;
  root: { [k: string]: unknown };
} | null;

After.

content?: LexicalRichText<LexicalNodes_0BDB72B5> | null;

export type LexicalNodes_0BDB72B5 =
  | SerializedTextNode
  | SerializedTabNode
  | SerializedLineBreakNode
  | SerializedParagraphNode<LexicalNodes_0BDB72B5>
  | SerializedHorizontalRuleNode
  | SerializedUploadNode<'uploads' | 'uploads2'>
  | SerializedQuoteNode<LexicalNodes_0BDB72B5>
  | SerializedRelationshipNode<'posts' | 'users' | /* ... */>
  | SerializedAutoLinkNode<LexicalNodes_0BDB72B5>
  | SerializedLinkNode<LexicalNodes_0BDB72B5>
  | SerializedListNode<LexicalNodes_0BDB72B5>
  | SerializedListItemNode<LexicalNodes_0BDB72B5>
  | SerializedHeadingNode<LexicalNodes_0BDB72B5>;

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 SerializedUploadNode instead, 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.ts and your data still parses.

The types are stricter now, though, so TypeScript can start flagging rich text code that used to slip through:

  • Reading a nested node (e.g. node.children) without first narrowing on node.type will error. Narrow by type and you get real autocomplete.
  • Reading arbitrary keys off rich text content no longer works - the old fully-loose { [k: string]: unknown } shape is gone.

TypedEditorState / DefaultTypedEditorState are stricter

These 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, TypedEditorState rewrote each node's children into the recursive union for you (an internal RecursiveNodes helper, capped at a fixed depth). Now TypedEditorState<T> uses T as-is, so a node's children come 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>) {
    // ...
  }

SerializedParagraphNode is an element node, so it takes the union (<MyNodes>) to type its children. SerializedTextNode is a leaf with no children, so it stays bare.

For the common case - the built-in nodes plus a few of your own - DefaultNodeTypesOf does the threading for you, and DefaultTypedEditorState with only built-in nodes needs no change:

type MyNodes = DefaultNodeTypesOf<MyNodes> | SerializedBlockNode<MyBlockData>

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 RecursiveNodes helper 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.

configToJSONSchema returns an object now

It used to return a JSONSchema4. It now returns { jsonSchema, typeStringDefinitions }.

- const schema = configToJSONSchema(sanitizedConfig, 'text')
+ const { jsonSchema: schema, typeStringDefinitions } = configToJSONSchema(sanitizedConfig, 'text')

fieldsToJSONSchema takes one object instead of 6 positional args

- fieldsToJSONSchema(
-   collectionIDFieldTypes,
-   fields,
-   interfaceNameDefinitions,
-   config,
-   i18n,
-   { forceInlineBlocks: true },
- )
+ fieldsToJSONSchema({
+   collectionIDFieldTypes,
+   config,
+   fields,
+   forceInlineBlocks: true,
+   i18n,
+   interfaceNameDefinitions,
+   typeStringDefinitions,
+ })

entityToJSONSchema got a new required argument

typeStringDefinitions is now a required positional argument at position 5. The old opts object becomes an optional forceInlineBlocks?: boolean at the end.

  entityToJSONSchema(
    config,
    entity,
    interfaceNameDefinitions,
    defaultIDType,
+   typeStringDefinitions,
    collectionIDFieldTypes,
    i18n,
-   { forceInlineBlocks: true },
+   true,
  )

Custom lexical features: generatedTypes.modifyJSONSchema is gone

Features used to contribute types by mutating the whole field schema after the fact, through generatedTypes.modifyJSONSchema (and the sanitized modifyJSONSchemas array). That's removed. Each node now contributes its own schema through a jsonSchema function on createNode, 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 jsonSchema function 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

sanitizeServerFeatures rejects two features registering the same node type. Before, it silently kept the last one.

How features contribute types

Each feature attaches a jsonSchema function to its node via createNode. The function gets a helper for the shared element shape and a Set<string> it can dump raw TS source into:

const SERIALIZED_QUOTE_NODE_TS = `export interface SerializedQuoteNode<TChildren> extends SerializedLexicalElementBase<TChildren> {
  type: 'quote';
}`

export const quoteNodeJSONSchema: JSONSchemaFn = ({
  elementNodeSchema,
  nodeUnionName,
  typeStringDefinitions,
}) => {
  typeStringDefinitions.add(SERIALIZED_QUOTE_NODE_TS)
  return elementNodeSchema({
    nodeType: 'quote',
    tsType: `SerializedQuoteNode<${nodeUnionName}>`,
  })
}

The same TS source string from many nodes only lands in the output once - Set<string> deduplicates for free. Nodes without jsonSchema stay as { [k: string]: unknown }, so features can opt in node by node.

Internal refactor changes

  • Shared lexical types live in types/builtInNodes.ts (SerializedLexicalElementBase, LexicalElementFormat, LexicalRichText, …). Per-node helpers live next to their schemas under features/*/server/schema.ts. nodeTypes.ts re-exports from the new locations.
  • For the MCP plugin, payload now exports entityToStandaloneJSONSchema, 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.

AlessioGr added 8 commits May 28, 2026 21:21
…-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
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 2, 2026

📦 esbuild Bundle Analysis for payload

This analysis was generated by esbuild-bundle-analyzer. 🤖

Meta File Out File Size (raw) Note
packages/next/meta_index.json esbuild/index.js 201.84 KB 🆕 Added
packages/payload/meta_index.json esbuild/index.js 1.41 MB 🆕 Added
packages/payload/meta_shared.json esbuild/exports/shared.js 192.51 KB 🆕 Added
packages/richtext-lexical/meta_client.json esbuild/exports/client_optimized/index.js 305.22 KB 🆕 Added
packages/ui/meta_client.json esbuild/exports/client_optimized/index.js 1.35 MB 🆕 Added
packages/ui/meta_shared.json esbuild/exports/shared_optimized/index.js 18.64 KB 🆕 Added
Largest paths These visualization shows top 20 largest paths in the bundle.

Meta file: packages/next/meta_index.json, Out file: esbuild/index.js

Path Size
../../node_modules ${{\color{Goldenrod}{ ████████████████████████▋ }}}$ 98.9%, 197.86 KB
dist/adapters/router.js ${{\color{Goldenrod}{ }}}$ 0.3%, 663 B
dist/adapters/server.js ${{\color{Goldenrod}{ }}}$ 0.3%, 533 B
dist/adapters/layout.js ${{\color{Goldenrod}{ }}}$ 0.3%, 526 B
dist/adapters/views.js ${{\color{Goldenrod}{ }}}$ 0.2%, 409 B
dist/esbuildEntry.js ${{\color{Goldenrod}{ }}}$ 0.0%, 0 B

Meta file: packages/payload/meta_index.json, Out file: esbuild/index.js

Path Size
../../node_modules ${{\color{Goldenrod}{ █████████████████ }}}$ 68.3%, 960.54 KB
dist/fields/hooks ${{\color{Goldenrod}{ ▊ }}}$ 3.1%, 44.05 KB
dist/collections/operations ${{\color{Goldenrod}{ ▋ }}}$ 2.9%, 40.26 KB
dist/versions/migrations ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 18.50 KB
dist/auth/operations ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 15.63 KB
dist/utilities/configToJSONSchema.js ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 15.01 KB
dist/fields/config ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 13.40 KB
dist/globals/operations ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 13.40 KB
dist/queues/operations ${{\color{Goldenrod}{ ▏ }}}$ 0.9%, 12.63 KB
dist/fields/validations.js ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 10.57 KB
dist/collections/config ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 9.53 KB
dist/bin/generateImportMap ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 9.47 KB
dist/config/orderable ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 7.92 KB
dist/uploads/fetchAPI-multipart ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 7.80 KB
dist/index.js ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 7.77 KB
dist/hierarchy/utils ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 7.64 KB
dist/database/migrations ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 7.55 KB
dist/collections/endpoints ${{\color{Goldenrod}{ }}}$ 0.4%, 6.23 KB
dist/queues/config ${{\color{Goldenrod}{ }}}$ 0.4%, 5.59 KB
dist/auth/strategies ${{\color{Goldenrod}{ }}}$ 0.4%, 5.43 KB
(other) ${{\color{Goldenrod}{ ███████▉ }}}$ 31.7%, 445.67 KB

Meta file: packages/payload/meta_shared.json, Out file: esbuild/exports/shared.js

Path Size
../../node_modules ${{\color{Goldenrod}{ ███████████████████▉ }}}$ 79.5%, 150.12 KB
dist/fields/validations.js ${{\color{Goldenrod}{ █▍ }}}$ 5.6%, 10.57 KB
dist/config/orderable ${{\color{Goldenrod}{ ▍ }}}$ 1.7%, 3.13 KB
dist/fields/baseFields ${{\color{Goldenrod}{ ▍ }}}$ 1.5%, 2.79 KB
dist/utilities/deepCopyObject.js ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 2.54 KB
dist/auth/cookies.js ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 1.55 KB
dist/utilities/flattenTopLevelFields.js ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 1.42 KB
dist/fields/config ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 1.29 KB
dist/utilities/getVersionsConfig.js ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 1.04 KB
dist/utilities/flattenAllFields.js ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 943 B
dist/utilities/unflatten.js ${{\color{Goldenrod}{ }}}$ 0.4%, 779 B
dist/utilities/sanitizeUserDataForEmail.js ${{\color{Goldenrod}{ }}}$ 0.4%, 713 B
dist/utilities/getFieldPermissions.js ${{\color{Goldenrod}{ }}}$ 0.3%, 651 B
dist/collections/config ${{\color{Goldenrod}{ }}}$ 0.3%, 570 B
dist/bin/generateImportMap ${{\color{Goldenrod}{ }}}$ 0.3%, 561 B
dist/auth/sessions.js ${{\color{Goldenrod}{ }}}$ 0.3%, 525 B
dist/fields/getFieldPaths.js ${{\color{Goldenrod}{ }}}$ 0.3%, 485 B
dist/utilities/appendDateTimezoneSelectFields.js ${{\color{Goldenrod}{ }}}$ 0.2%, 451 B
dist/utilities/getSafeRedirect.js ${{\color{Goldenrod}{ }}}$ 0.2%, 423 B
dist/utilities/deepMerge.js ${{\color{Goldenrod}{ }}}$ 0.2%, 413 B
(other) ${{\color{Goldenrod}{ █████▏ }}}$ 20.5%, 38.61 KB

Meta file: packages/richtext-lexical/meta_client.json, Out file: esbuild/exports/client_optimized/index.js

Path Size
dist/features/blocks ${{\color{Goldenrod}{ ███ }}}$ 12.4%, 37.39 KB
dist/lexical/ui ${{\color{Goldenrod}{ ██▊ }}}$ 11.3%, 34.16 KB
dist/lexical/plugins ${{\color{Goldenrod}{ ██▋ }}}$ 10.9%, 32.88 KB
dist/features/experimental_table ${{\color{Goldenrod}{ ██▎ }}}$ 9.0%, 27.16 KB
dist/packages/@lexical ${{\color{Goldenrod}{ █▌ }}}$ 6.3%, 18.99 KB
dist/features/link ${{\color{Goldenrod}{ █▌ }}}$ 6.2%, 18.82 KB
dist/features/toolbars ${{\color{Goldenrod}{ █▍ }}}$ 5.5%, 16.58 KB
dist/features/upload ${{\color{Goldenrod}{ █▏ }}}$ 4.7%, 14.09 KB
dist/features/textState ${{\color{Goldenrod}{ ▉ }}}$ 3.7%, 11.08 KB
dist/features/relationship ${{\color{Goldenrod}{ ▊ }}}$ 3.2%, 9.61 KB
dist/lexical/utils ${{\color{Goldenrod}{ ▋ }}}$ 2.9%, 8.79 KB
dist/features/converters ${{\color{Goldenrod}{ ▋ }}}$ 2.8%, 8.36 KB
dist/utilities/fieldsDrawer ${{\color{Goldenrod}{ ▋ }}}$ 2.7%, 8.12 KB
dist/features/debug ${{\color{Goldenrod}{ ▌ }}}$ 2.4%, 7.40 KB
dist/lexical/config ${{\color{Goldenrod}{ ▍ }}}$ 1.7%, 5.08 KB
dist/features/lists ${{\color{Goldenrod}{ ▍ }}}$ 1.7%, 5.00 KB
dist/features/format ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 3.46 KB
dist/lexical/LexicalEditor.js ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 3.23 KB
dist/features/horizontalRule ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 3.18 KB
dist/field/Field.js ${{\color{Goldenrod}{ ▏ }}}$ 0.9%, 2.84 KB
(other) ${{\color{Goldenrod}{ █████████████████████▉ }}}$ 87.6%, 264.60 KB

Meta file: packages/ui/meta_client.json, Out file: esbuild/exports/client_optimized/index.js

Path Size
../../node_modules ${{\color{Goldenrod}{ ██████████▊ }}}$ 43.3%, 580.43 KB
dist/elements/Hierarchy ${{\color{Goldenrod}{ ▊ }}}$ 3.2%, 43.08 KB
dist/elements/BulkUpload ${{\color{Goldenrod}{ ▌ }}}$ 2.1%, 28.13 KB
dist/views/Version ${{\color{Goldenrod}{ ▌ }}}$ 2.0%, 27.46 KB
dist/views/HierarchyList ${{\color{Goldenrod}{ ▍ }}}$ 1.5%, 20.43 KB
dist/elements/Table ${{\color{Goldenrod}{ ▎ }}}$ 1.4%, 19.28 KB
dist/views/Dashboard ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 17.73 KB
dist/views/Edit ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 17.57 KB
dist/elements/WhereBuilder ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 17.34 KB
dist/forms/Form ${{\color{Goldenrod}{ ▎ }}}$ 1.2%, 15.89 KB
dist/fields/Relationship ${{\color{Goldenrod}{ ▎ }}}$ 1.2%, 15.48 KB
dist/fields/Blocks ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 15.11 KB
dist/fields/Upload ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 14.40 KB
dist/elements/QueryPresets ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 10.22 KB
dist/elements/PublishButton ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 8.67 KB
dist/elements/HTMLDiff ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 8.38 KB
dist/elements/LivePreview ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 8.26 KB
dist/views/List ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 8.15 KB
dist/elements/ReactSelect ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 7.83 KB
dist/fields/Array ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 7.76 KB
(other) ${{\color{Goldenrod}{ ██████████████▏ }}}$ 56.7%, 759.56 KB

Meta file: packages/ui/meta_shared.json, Out file: esbuild/exports/shared_optimized/index.js

Path Size
dist/graphics/Logo ${{\color{Goldenrod}{ ███████▋ }}}$ 30.9%, 5.57 KB
../../node_modules ${{\color{Goldenrod}{ ███▋ }}}$ 14.7%, 2.65 KB
dist/graphics/Icon ${{\color{Goldenrod}{ ██▏ }}}$ 8.5%, 1.52 KB
dist/utilities/formatDocTitle ${{\color{Goldenrod}{ █▊ }}}$ 7.4%, 1.32 KB
dist/providers/TableColumns ${{\color{Goldenrod}{ █▏ }}}$ 4.8%, 866 B
dist/utilities/getGlobalData.js ${{\color{Goldenrod}{ █ }}}$ 4.2%, 762 B
dist/utilities/api.js ${{\color{Goldenrod}{ █ }}}$ 4.2%, 756 B
dist/utilities/groupNavItems.js ${{\color{Goldenrod}{ █ }}}$ 4.1%, 745 B
dist/elements/Translation ${{\color{Goldenrod}{ ▋ }}}$ 2.7%, 493 B
dist/utilities/handleTakeOver.js ${{\color{Goldenrod}{ ▌ }}}$ 2.4%, 440 B
dist/utilities/traverseForLocalizedFields.js ${{\color{Goldenrod}{ ▌ }}}$ 2.2%, 399 B
dist/elements/withMergedProps ${{\color{Goldenrod}{ ▍ }}}$ 1.9%, 339 B
dist/utilities/getNavGroups.js ${{\color{Goldenrod}{ ▍ }}}$ 1.9%, 338 B
dist/utilities/getVisibleEntities.js ${{\color{Goldenrod}{ ▍ }}}$ 1.8%, 329 B
dist/elements/WithServerSideProps ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 232 B
dist/utilities/handleGoBack.js ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 180 B
dist/fields/mergeFieldStyles.js ${{\color{Goldenrod}{ ▏ }}}$ 0.9%, 157 B
dist/utilities/handleBackToDashboard.js ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 152 B
dist/forms/Form ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 148 B
dist/utilities/abortAndIgnore.js ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 146 B
(other) ${{\color{Goldenrod}{ █████████████████▎ }}}$ 69.1%, 12.45 KB
Details

Next to the size is how much the size has increased or decreased compared with the base branch of this PR.

  • ‼️: Size increased by 20% or more. Special attention should be given to this.
  • ⚠️: Size increased in acceptable range (lower than 20%).
  • ✅: No change or even downsized.
  • 🗑️: The out file is deleted: not found in base branch.
  • 🆕: The out file is newly found: will be added to base branch.

AlessioGr added 3 commits June 2, 2026 18:46
…-types

# Conflicts:
#	docs/migration-guide/v4.mdx
#	packages/payload/src/fields/config/types.ts
#	packages/payload/src/utilities/configToJSONSchema.ts
Copy link
Copy Markdown
Contributor

@GermanJablo GermanJablo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 LinkFeature fields should generate different link field types.
  • Two different blocks with the same explicit interfaceName should not silently overwrite each other.
  • A custom Lexical node without jsonSchema should either appear as unknown in 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.

Copy link
Copy Markdown
Contributor

@GermanJablo GermanJablo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.create accepts a valid richText value generated with buildEditorState<Post['richText']>().
  • payload.update accepts a valid generated rich text value.
  • payload.update rejects an invalid lexical object, like a node with an unknown type, a block node missing required fields, or an upload node whose fields do not match its upload collection.
  • payload.updateGlobal has the same coverage for a global rich text field.
  • The SDK create and update APIs 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.

GermanJablo and others added 4 commits June 5, 2026 11:25
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`.
@AlessioGr AlessioGr requested a review from GermanJablo June 5, 2026 16:53
@AlessioGr AlessioGr merged commit d494428 into main Jun 5, 2026
329 of 336 checks passed
@AlessioGr AlessioGr deleted the feat/accurate-lexical-types branch June 5, 2026 17:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants