Skip to content

Commit 4c8cafd

Browse files
authored
perf: deduplicate blocks used in multiple places using new config.blocks property (#10905)
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`.
1 parent 152a9b6 commit 4c8cafd

File tree

113 files changed

+41018
-1848
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

113 files changed

+41018
-1848
lines changed

.github/workflows/main.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,7 @@ jobs:
283283
- fields-relationship
284284
- fields__collections__Array
285285
- fields__collections__Blocks
286+
- fields__collections__Blocks#config.blockreferences.ts
286287
- fields__collections__Checkbox
287288
- fields__collections__Collapsible
288289
- fields__collections__ConditionalLogic
@@ -293,6 +294,7 @@ jobs:
293294
- fields__collections__JSON
294295
- fields__collections__Lexical__e2e__main
295296
- fields__collections__Lexical__e2e__blocks
297+
- fields__collections__Lexical__e2e__blocks#config.blockreferences.ts
296298
- fields__collections__Number
297299
- fields__collections__Point
298300
- fields__collections__Radio

docs/fields/blocks.mdx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,70 @@ export const CustomBlocksFieldLabelClient: BlocksFieldLabelClientComponent = ({
295295
}
296296
```
297297

298+
## Block References
299+
300+
If you have multiple blocks used in multiple places, your Payload Config can grow in size, potentially sending more data to the client and requiring more processing on the server. However, you can optimize performance by defining each block **once** in your Payload Config and then referencing its slug wherever it's used instead of passing the entire block config.
301+
302+
To do this, define the block in the `blocks` array of the Payload Config. Then, in the Blocks Field, pass the block slug to the `blockReferences` array - leaving the `blocks` array empty for compatibility reasons.
303+
304+
```ts
305+
import { buildConfig } from 'payload'
306+
import { lexicalEditor, BlocksFeature } from '@payloadcms/richtext-lexical'
307+
308+
// Payload Config
309+
const config = buildConfig({
310+
// Define the block once
311+
blocks: [
312+
{
313+
slug: 'TextBlock',
314+
fields: [
315+
{
316+
name: 'text',
317+
type: 'text',
318+
},
319+
],
320+
},
321+
],
322+
collections: [
323+
{
324+
slug: 'collection1',
325+
fields: [
326+
{
327+
name: 'content',
328+
type: 'blocks',
329+
// Reference the block by slug
330+
blockReferences: ['TextBlock'],
331+
blocks: [], // Required to be empty, for compatibility reasons
332+
},
333+
],
334+
},
335+
{
336+
slug: 'collection2',
337+
fields: [
338+
{
339+
name: 'editor',
340+
type: 'richText',
341+
editor: lexicalEditor({
342+
BlocksFeature({
343+
// Same reference can be reused anywhere, even in the lexical editor, without incurred performance hit
344+
blocks: ['TextBlock'],
345+
})
346+
})
347+
},
348+
],
349+
},
350+
],
351+
})
352+
```
353+
354+
<Banner type="warning">
355+
**Reminder:**
356+
Blocks referenced in the `blockReferences` array are treated as isolated from the collection / global config. This has the following implications:
357+
358+
1. The block config cannot be modified or extended in the collection config. It will be identical everywhere it's referenced.
359+
2. Access control for blocks referenced in the `blockReferences` are run only once - data from the collection will not be available in the block's access control.
360+
</Banner>
361+
298362
## TypeScript
299363

300364
As you build your own Block configs, you might want to store them in separate files but retain typing accordingly. To do so, you can import and use Payload's `Block` type:

packages/db-mongodb/src/models/buildSchema.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import type { IndexOptions, Schema, SchemaOptions, SchemaTypeOptions } from 'mon
33
import mongoose from 'mongoose'
44
import {
55
type ArrayField,
6-
type Block,
76
type BlocksField,
87
type CheckboxField,
98
type CodeField,
@@ -193,11 +192,12 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
193192
schema.add({
194193
[field.name]: localizeSchema(field, fieldSchema, payload.config.localization),
195194
})
196-
197-
field.blocks.forEach((blockItem: Block) => {
195+
;(field.blockReferences ?? field.blocks).forEach((blockItem) => {
198196
const blockSchema = new mongoose.Schema({}, { _id: false, id: false })
199197

200-
blockItem.fields.forEach((blockField) => {
198+
const block = typeof blockItem === 'string' ? payload.blocks[blockItem] : blockItem
199+
200+
block.fields.forEach((blockField) => {
201201
const addFieldSchema: FieldSchemaGenerator = fieldToSchemaMap[blockField.type]
202202
if (addFieldSchema) {
203203
addFieldSchema(blockField, blockSchema, payload, buildSchemaOptions)
@@ -207,11 +207,11 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
207207
if (field.localized && payload.config.localization) {
208208
payload.config.localization.localeCodes.forEach((localeCode) => {
209209
// @ts-expect-error Possible incorrect typing in mongoose types, this works
210-
schema.path(`${field.name}.${localeCode}`).discriminator(blockItem.slug, blockSchema)
210+
schema.path(`${field.name}.${localeCode}`).discriminator(block.slug, blockSchema)
211211
})
212212
} else {
213213
// @ts-expect-error Possible incorrect typing in mongoose types, this works
214-
schema.path(field.name).discriminator(blockItem.slug, blockSchema)
214+
schema.path(field.name).discriminator(block.slug, blockSchema)
215215
}
216216
})
217217
},

packages/db-mongodb/src/predefinedMigrations/migrateRelationshipsV2_V3.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ const hasRelationshipOrUploadField = ({ fields }: { fields: Field[] }): boolean
8080

8181
if ('blocks' in field) {
8282
for (const block of field.blocks) {
83+
if (typeof block === 'string') {
84+
// Skip - string blocks have been added in v3 and thus don't need to be migrated
85+
continue
86+
}
8387
if (hasRelationshipOrUploadField({ fields: block.fields })) {
8488
return true
8589
}

packages/db-mongodb/src/queries/getLocalizedSortProperty.ts

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -65,18 +65,24 @@ export const getLocalizedSortProperty = ({
6565
}
6666

6767
if (matchedField.type === 'blocks') {
68-
nextFields = matchedField.blocks.reduce((flattenedBlockFields, block) => {
69-
return [
70-
...flattenedBlockFields,
71-
...block.flattenedFields.filter(
72-
(blockField) =>
73-
(fieldAffectsData(blockField) &&
74-
blockField.name !== 'blockType' &&
75-
blockField.name !== 'blockName') ||
76-
!fieldAffectsData(blockField),
77-
),
78-
]
79-
}, [])
68+
nextFields = (matchedField.blockReferences ?? matchedField.blocks).reduce(
69+
(flattenedBlockFields, _block) => {
70+
// TODO: iterate over blocks mapped to block slug in v4, or pass through payload.blocks
71+
const block =
72+
typeof _block === 'string' ? config.blocks.find((b) => b.slug === _block) : _block
73+
return [
74+
...flattenedBlockFields,
75+
...block.flattenedFields.filter(
76+
(blockField) =>
77+
(fieldAffectsData(blockField) &&
78+
blockField.name !== 'blockType' &&
79+
blockField.name !== 'blockName') ||
80+
!fieldAffectsData(blockField),
81+
),
82+
]
83+
},
84+
[],
85+
)
8086
}
8187

8288
const result = incomingResult ? `${incomingResult}.${localizedSegment}` : localizedSegment

packages/db-mongodb/src/queries/sanitizeQueryValue.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import type { FlattenedBlock, FlattenedField, Payload, RelationshipField } from 'payload'
1+
import type {
2+
FlattenedBlock,
3+
FlattenedBlocksField,
4+
FlattenedField,
5+
Payload,
6+
RelationshipField,
7+
} from 'payload'
28

39
import { Types } from 'mongoose'
410
import { createArrayFromCommaDelineated } from 'payload'
@@ -40,14 +46,18 @@ const buildExistsQuery = (formattedValue, path, treatEmptyString = true) => {
4046
// returns nestedField Field object from blocks.nestedField path because getLocalizedPaths splits them only for relationships
4147
const getFieldFromSegments = ({
4248
field,
49+
payload,
4350
segments,
4451
}: {
4552
field: FlattenedBlock | FlattenedField
53+
payload: Payload
4654
segments: string[]
4755
}) => {
48-
if ('blocks' in field) {
49-
for (const block of field.blocks) {
50-
const field = getFieldFromSegments({ field: block, segments })
56+
if ('blocks' in field || 'blockReferences' in field) {
57+
const _field: FlattenedBlocksField = field as FlattenedBlocksField
58+
for (const _block of _field.blockReferences ?? _field.blocks) {
59+
const block: FlattenedBlock = typeof _block === 'string' ? payload.blocks[_block] : _block
60+
const field = getFieldFromSegments({ field: block, payload, segments })
5161
if (field) {
5262
return field
5363
}
@@ -67,7 +77,7 @@ const getFieldFromSegments = ({
6777
}
6878

6979
segments.shift()
70-
return getFieldFromSegments({ field: foundField, segments })
80+
return getFieldFromSegments({ field: foundField, payload, segments })
7181
}
7282
}
7383
}
@@ -91,7 +101,7 @@ export const sanitizeQueryValue = ({
91101
if (['array', 'blocks', 'group', 'tab'].includes(field.type) && path.includes('.')) {
92102
const segments = path.split('.')
93103
segments.shift()
94-
const foundField = getFieldFromSegments({ field, segments })
104+
const foundField = getFieldFromSegments({ field, payload, segments })
95105

96106
if (foundField) {
97107
field = foundField

packages/db-mongodb/src/utilities/buildProjectionFromSelect.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,8 @@ const traverseFields = ({
123123
case 'blocks': {
124124
const blocksSelect = select[field.name] as SelectType
125125

126-
for (const block of field.blocks) {
126+
for (const _block of field.blockReferences ?? field.blocks) {
127+
const block = typeof _block === 'string' ? adapter.payload.blocks[_block] : _block
127128
if (
128129
(selectMode === 'include' && blocksSelect[block.slug] === true) ||
129130
(selectMode === 'exclude' && typeof blocksSelect[block.slug] === 'undefined')

packages/db-mongodb/src/utilities/sanitizeRelationshipIDs.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { CollectionConfig, Field, SanitizedConfig, TraverseFieldsCallback } from 'payload'
22

33
import { Types } from 'mongoose'
4-
import { APIError, traverseFields } from 'payload'
4+
import { traverseFields } from 'payload'
55
import { fieldAffectsData } from 'payload/shared'
66

77
type Args = {
@@ -150,7 +150,7 @@ export const sanitizeRelationshipIDs = ({
150150
}
151151
}
152152

153-
traverseFields({ callback: sanitize, fields, fillEmpty: false, ref: data })
153+
traverseFields({ callback: sanitize, config, fields, fillEmpty: false, ref: data })
154154

155155
return data
156156
}

packages/drizzle/src/find/traverseFields.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,8 @@ export const traverseFields = ({
185185
}
186186
}
187187

188-
field.blocks.forEach((block) => {
188+
;(field.blockReferences ?? field.blocks).forEach((_block) => {
189+
const block = typeof _block === 'string' ? adapter.payload.blocks[_block] : _block
189190
const blockKey = `_blocks_${block.slug}`
190191

191192
let blockSelect: boolean | SelectType | undefined

packages/drizzle/src/postgres/predefinedMigrations/v2-v3/fetchAndResave/traverseFields.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { FlattenedField } from 'payload'
1+
import type { FlattenedBlock, FlattenedField } from 'payload'
22

33
type Args = {
44
doc: Record<string, unknown>
@@ -51,7 +51,10 @@ export const traverseFields = ({ doc, fields, locale, path, rows }: Args) => {
5151
Object.entries(rowData).forEach(([locale, localeRows]) => {
5252
if (Array.isArray(localeRows)) {
5353
localeRows.forEach((row, i) => {
54-
const matchedBlock = field.blocks.find((block) => block.slug === row.blockType)
54+
// Can ignore string blocks, as those were added in v3 and don't need to be migrated
55+
const matchedBlock = field.blocks.find(
56+
(block) => typeof block !== 'string' && block.slug === row.blockType,
57+
) as FlattenedBlock | undefined
5558

5659
if (matchedBlock) {
5760
return traverseFields({
@@ -69,7 +72,10 @@ export const traverseFields = ({ doc, fields, locale, path, rows }: Args) => {
6972

7073
if (Array.isArray(rowData)) {
7174
rowData.forEach((row, i) => {
72-
const matchedBlock = field.blocks.find((block) => block.slug === row.blockType)
75+
// Can ignore string blocks, as those were added in v3 and don't need to be migrated
76+
const matchedBlock = field.blocks.find(
77+
(block) => typeof block !== 'string' && block.slug === row.blockType,
78+
) as FlattenedBlock | undefined
7379

7480
if (matchedBlock) {
7581
return traverseFields({

0 commit comments

Comments
 (0)