If you have multiple blocks that are used in multiple places, this can quickly blow up the size of your Payload Config. This will incur a performance hit, as more data is
1. sent to the client (=> bloated `ClientConfig` and large initial html) and
2. processed on the server (permissions are calculated every single time you navigate to a page - this iterates through all blocks you have defined, even if they're duplicative)
This can be optimized by defining your block **once** in your Payload Config, and just referencing the block slug whenever it's used, instead of passing the entire block config. To do this, the block can be defined in the `blocks` array of the Payload Config. The slug can then be passed to the `blockReferences` array in the Blocks Field - the `blocks` array has to be empty for compatibility reasons.
```ts
import { buildConfig } from 'payload'
import { lexicalEditor, BlocksFeature } from '@payloadcms/richtext-lexical'
// Payload Config
const config = buildConfig({
// Define the block once
blocks: [
{
slug: 'TextBlock',
fields: [
{
name: 'text',
type: 'text',
},
],
},
],
collections: [
{
slug: 'collection1',
fields: [
{
name: 'content',
type: 'blocks',
// Reference the block by slug
blockReferences: ['TextBlock'],
blocks: [], // Required to be empty, for compatibility reasons
},
],
},
{
slug: 'collection2',
fields: [
{
name: 'editor',
type: 'richText',
editor: lexicalEditor({
BlocksFeature({
// Same reference can be reused anywhere, even in the lexical editor, without incurred performance hit
blocks: ['TextBlock'],
})
})
},
],
},
],
})
```
## v4.0 Plans
In 4.0, we will remove the `blockReferences` property, and allow string block references to be passed directly to the blocks `property`. Essentially, we'd remove the `blocks` property and rename `blockReferences` to `blocks`.
The reason we opted to a new property in this PR is to avoid breaking changes. Allowing strings to be passed to the `blocks` property will prevent plugins that iterate through fields / blocks from compiling.
## PR Changes
- Testing: This PR introduces a plugin that automatically converts blocks to block references. This is done in the fields__blocks test suite, to run our existing test suite using block references.
- Block References support: Most changes are similar. Everywhere we iterate through blocks, we have to now do the following:
1. Check if `field.blockReferences` is provided. If so, only iterate through that.
2. Check if the block is an object (= actual block), or string
3. If it's a string, pull the actual block from the Payload Config or from `payload.blocks`.
The exception is config sanitization and block type generations. This PR optimizes them so that each block is only handled once, instead of every time the block is referenced.
## Benchmarks
60 Block fields, each block field having the same 600 Blocks.
### Before:
**Initial HTML:** 195 kB
**Generated types:** takes 11 minutes, 461,209 lines
https://github.com/user-attachments/assets/11d49a4e-5414-4579-8050-e6346e552f56
### After:
**Initial HTML:** 73.6 kB
**Generated types:** takes 2 seconds, 35,810 lines
https://github.com/user-attachments/assets/3eab1a99-6c29-489d-add5-698df67780a3
### After Permissions Optimization (follow-up PR)
Initial HTML: 73.6 kB
https://github.com/user-attachments/assets/a909202e-45a8-4bf6-9a38-8c85813f1312
## Future Plans
1. This PR does not yet deduplicate block references during permissions calculation. We'll optimize that in a separate PR, as this one is already large enough
2. The same optimization can be done to deduplicate fields. One common use-case would be link field groups that may be referenced in multiple entities, outside of blocks. We might explore adding a new `fieldReferences` property, that allows you to reference those same `config.blocks`.
409 lines
9.5 KiB
TypeScript
409 lines
9.5 KiB
TypeScript
import type { ServerEditorConfig } from '@payloadcms/richtext-lexical'
|
|
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
|
|
import type { Block, BlockSlug, CollectionConfig } from 'payload'
|
|
|
|
import {
|
|
BlocksFeature,
|
|
defaultEditorFeatures,
|
|
EXPERIMENTAL_TableFeature,
|
|
FixedToolbarFeature,
|
|
getEnabledNodes,
|
|
HeadingFeature,
|
|
lexicalEditor,
|
|
LinkFeature,
|
|
sanitizeServerEditorConfig,
|
|
TreeViewFeature,
|
|
UploadFeature,
|
|
} from '@payloadcms/richtext-lexical'
|
|
import { createHeadlessEditor } from '@payloadcms/richtext-lexical/lexical/headless'
|
|
import { $convertToMarkdownString } from '@payloadcms/richtext-lexical/lexical/markdown'
|
|
|
|
import { lexicalFieldsSlug } from '../../slugs.js'
|
|
import {
|
|
AsyncHooksBlock,
|
|
CodeBlock,
|
|
ConditionalLayoutBlock,
|
|
FilterOptionsBlock,
|
|
RadioButtonsBlock,
|
|
RelationshipBlock,
|
|
RelationshipHasManyBlock,
|
|
RichTextBlock,
|
|
SelectFieldBlock,
|
|
SubBlockBlock,
|
|
TabBlock,
|
|
TextBlock,
|
|
UploadAndRichTextBlock,
|
|
ValidationBlock,
|
|
} from './blocks.js'
|
|
import { ModifyInlineBlockFeature } from './ModifyInlineBlockFeature/feature.server.js'
|
|
|
|
export const lexicalBlocks: (Block | BlockSlug)[] = [
|
|
ValidationBlock,
|
|
FilterOptionsBlock,
|
|
AsyncHooksBlock,
|
|
RichTextBlock,
|
|
TextBlock,
|
|
UploadAndRichTextBlock,
|
|
SelectFieldBlock,
|
|
RelationshipBlock,
|
|
RelationshipHasManyBlock,
|
|
SubBlockBlock,
|
|
RadioButtonsBlock,
|
|
ConditionalLayoutBlock,
|
|
TabBlock,
|
|
CodeBlock,
|
|
{
|
|
slug: 'myBlock',
|
|
admin: {
|
|
components: {},
|
|
},
|
|
fields: [
|
|
{
|
|
name: 'key',
|
|
label: () => {
|
|
return 'Key'
|
|
},
|
|
type: 'select',
|
|
options: ['value1', 'value2', 'value3'],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
slug: 'myBlockWithLabel',
|
|
admin: {
|
|
components: {
|
|
Label: '/collections/Lexical/blockComponents/LabelComponent.js#LabelComponent',
|
|
},
|
|
},
|
|
fields: [
|
|
{
|
|
name: 'key',
|
|
label: () => {
|
|
return 'Key'
|
|
},
|
|
type: 'select',
|
|
options: ['value1', 'value2', 'value3'],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
slug: 'myBlockWithBlock',
|
|
admin: {
|
|
components: {
|
|
Block: '/collections/Lexical/blockComponents/BlockComponent.js#BlockComponent',
|
|
},
|
|
},
|
|
fields: [
|
|
{
|
|
name: 'key',
|
|
label: () => {
|
|
return 'Key'
|
|
},
|
|
type: 'select',
|
|
options: ['value1', 'value2', 'value3'],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
slug: 'BlockRSC',
|
|
|
|
admin: {
|
|
components: {
|
|
Block: '/collections/Lexical/blockComponents/BlockComponentRSC.js#BlockComponentRSC',
|
|
},
|
|
},
|
|
fields: [
|
|
{
|
|
name: 'key',
|
|
label: () => {
|
|
return 'Key'
|
|
},
|
|
type: 'select',
|
|
options: ['value1', 'value2', 'value3'],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
slug: 'myBlockWithBlockAndLabel',
|
|
admin: {
|
|
components: {
|
|
Block: '/collections/Lexical/blockComponents/BlockComponent.js#BlockComponent',
|
|
Label: '/collections/Lexical/blockComponents/LabelComponent.js#LabelComponent',
|
|
},
|
|
},
|
|
fields: [
|
|
{
|
|
name: 'key',
|
|
label: () => {
|
|
return 'Key'
|
|
},
|
|
type: 'select',
|
|
options: ['value1', 'value2', 'value3'],
|
|
},
|
|
],
|
|
},
|
|
]
|
|
|
|
export const lexicalInlineBlocks: (Block | BlockSlug)[] = [
|
|
{
|
|
slug: 'AvatarGroup',
|
|
interfaceName: 'AvatarGroupBlock',
|
|
fields: [
|
|
{
|
|
name: 'avatars',
|
|
type: 'array',
|
|
minRows: 1,
|
|
maxRows: 6,
|
|
fields: [
|
|
{
|
|
name: 'image',
|
|
type: 'upload',
|
|
relationTo: 'uploads',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
slug: 'myInlineBlock',
|
|
admin: {
|
|
components: {},
|
|
},
|
|
fields: [
|
|
{
|
|
name: 'key',
|
|
label: () => {
|
|
return 'Key'
|
|
},
|
|
type: 'select',
|
|
options: ['value1', 'value2', 'value3'],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
slug: 'myInlineBlockWithLabel',
|
|
admin: {
|
|
components: {
|
|
Label: '/collections/Lexical/inlineBlockComponents/LabelComponent.js#LabelComponent',
|
|
},
|
|
},
|
|
fields: [
|
|
{
|
|
name: 'key',
|
|
label: () => {
|
|
return 'Key'
|
|
},
|
|
type: 'select',
|
|
options: ['value1', 'value2', 'value3'],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
slug: 'myInlineBlockWithBlock',
|
|
admin: {
|
|
components: {
|
|
Block: '/collections/Lexical/inlineBlockComponents/BlockComponent.js#BlockComponent',
|
|
},
|
|
},
|
|
fields: [
|
|
{
|
|
name: 'key',
|
|
label: () => {
|
|
return 'Key'
|
|
},
|
|
type: 'select',
|
|
options: ['value1', 'value2', 'value3'],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
slug: 'myInlineBlockWithBlockAndLabel',
|
|
admin: {
|
|
components: {
|
|
Block: '/collections/Lexical/inlineBlockComponents/BlockComponent.js#BlockComponent',
|
|
Label: '/collections/Lexical/inlineBlockComponents/LabelComponent.js#LabelComponent',
|
|
},
|
|
},
|
|
fields: [
|
|
{
|
|
name: 'key',
|
|
label: () => {
|
|
return 'Key'
|
|
},
|
|
type: 'select',
|
|
options: ['value1', 'value2', 'value3'],
|
|
},
|
|
],
|
|
},
|
|
]
|
|
|
|
export const getLexicalFieldsCollection: (args: {
|
|
blocks: (Block | BlockSlug)[]
|
|
inlineBlocks: (Block | BlockSlug)[]
|
|
}) => CollectionConfig = ({ blocks, inlineBlocks }) => {
|
|
const editorConfig: ServerEditorConfig = {
|
|
features: [
|
|
...defaultEditorFeatures,
|
|
//TestRecorderFeature(),
|
|
TreeViewFeature(),
|
|
//HTMLConverterFeature(),
|
|
FixedToolbarFeature(),
|
|
LinkFeature({
|
|
fields: ({ defaultFields }) => [
|
|
...defaultFields,
|
|
{
|
|
name: 'rel',
|
|
type: 'select',
|
|
admin: {
|
|
description:
|
|
'The rel attribute defines the relationship between a linked resource and the current document. This is a custom link field.',
|
|
},
|
|
hasMany: true,
|
|
label: 'Rel Attribute',
|
|
options: ['noopener', 'noreferrer', 'nofollow'],
|
|
},
|
|
],
|
|
}),
|
|
UploadFeature({
|
|
collections: {
|
|
uploads: {
|
|
fields: [
|
|
{
|
|
name: 'caption',
|
|
type: 'richText',
|
|
editor: lexicalEditor(),
|
|
},
|
|
],
|
|
},
|
|
},
|
|
}),
|
|
ModifyInlineBlockFeature(),
|
|
BlocksFeature({
|
|
blocks,
|
|
inlineBlocks,
|
|
}),
|
|
EXPERIMENTAL_TableFeature(),
|
|
],
|
|
}
|
|
return {
|
|
slug: lexicalFieldsSlug,
|
|
access: {
|
|
read: () => true,
|
|
},
|
|
admin: {
|
|
listSearchableFields: ['title', 'richTextLexicalCustomFields'],
|
|
useAsTitle: 'title',
|
|
},
|
|
fields: [
|
|
{
|
|
name: 'title',
|
|
type: 'text',
|
|
required: true,
|
|
},
|
|
{
|
|
name: 'lexicalRootEditor',
|
|
type: 'richText',
|
|
},
|
|
{
|
|
name: 'lexicalSimple',
|
|
type: 'richText',
|
|
editor: lexicalEditor({
|
|
features: ({ defaultFeatures }) => [
|
|
//TestRecorderFeature(),
|
|
TreeViewFeature(),
|
|
BlocksFeature({
|
|
blocks: [
|
|
RichTextBlock,
|
|
TextBlock,
|
|
UploadAndRichTextBlock,
|
|
SelectFieldBlock,
|
|
RelationshipBlock,
|
|
RelationshipHasManyBlock,
|
|
SubBlockBlock,
|
|
RadioButtonsBlock,
|
|
ConditionalLayoutBlock,
|
|
],
|
|
}),
|
|
HeadingFeature({ enabledHeadingSizes: ['h2', 'h4'] }),
|
|
],
|
|
}),
|
|
},
|
|
{
|
|
type: 'ui',
|
|
name: 'clearLexicalState',
|
|
admin: {
|
|
components: {
|
|
Field: {
|
|
path: '/collections/Lexical/components/ClearState.js#ClearState',
|
|
clientProps: {
|
|
fieldName: 'lexicalSimple',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: 'lexicalWithBlocks',
|
|
type: 'richText',
|
|
editor: lexicalEditor({
|
|
admin: {
|
|
hideGutter: false,
|
|
},
|
|
features: editorConfig.features,
|
|
}),
|
|
required: true,
|
|
},
|
|
//{
|
|
// name: 'rendered',
|
|
// type: 'ui',
|
|
// admin: {
|
|
// components: {
|
|
// Field: './collections/Lexical/LexicalRendered.js#LexicalRendered',
|
|
// },
|
|
// },
|
|
//},
|
|
{
|
|
name: 'lexicalWithBlocks_markdown',
|
|
type: 'textarea',
|
|
hooks: {
|
|
afterRead: [
|
|
async ({ data, req, siblingData }) => {
|
|
const yourSanitizedEditorConfig = await sanitizeServerEditorConfig(
|
|
editorConfig,
|
|
req.payload.config,
|
|
)
|
|
|
|
const headlessEditor = createHeadlessEditor({
|
|
nodes: getEnabledNodes({
|
|
editorConfig: yourSanitizedEditorConfig,
|
|
}),
|
|
})
|
|
|
|
const yourEditorState: SerializedEditorState = siblingData.lexicalWithBlocks
|
|
try {
|
|
headlessEditor.update(
|
|
() => {
|
|
headlessEditor.setEditorState(headlessEditor.parseEditorState(yourEditorState))
|
|
},
|
|
{ discrete: true },
|
|
)
|
|
} catch (e) {
|
|
/* empty */
|
|
}
|
|
|
|
// Export to markdown
|
|
let markdown: string = ''
|
|
headlessEditor.getEditorState().read(() => {
|
|
markdown = $convertToMarkdownString(
|
|
yourSanitizedEditorConfig?.features?.markdownTransformers,
|
|
)
|
|
})
|
|
return markdown
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
}
|
|
}
|