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`.
This commit is contained in:
104
test/helpers/autoDedupeBlocksPlugin/index.ts
Normal file
104
test/helpers/autoDedupeBlocksPlugin/index.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { type Block, type BlockSlug, type Config, traverseFields } from 'payload'
|
||||
|
||||
export const autoDedupeBlocksPlugin =
|
||||
(args?: { debug?: boolean; disabled?: boolean; silent?: boolean }) =>
|
||||
(config: Config): Config => {
|
||||
if (!args) {
|
||||
args = {}
|
||||
}
|
||||
const { disabled = false, debug = false, silent = false } = args
|
||||
|
||||
if (disabled) {
|
||||
return config
|
||||
}
|
||||
|
||||
traverseFields({
|
||||
config,
|
||||
leavesFirst: true,
|
||||
isTopLevel: true,
|
||||
fields: [
|
||||
...(config.collections?.length
|
||||
? config.collections.map((collection) => collection.fields).flat()
|
||||
: []),
|
||||
...(config.globals?.length ? config.globals.map((global) => global.fields).flat() : []),
|
||||
],
|
||||
callback: ({ field }) => {
|
||||
if (field.type === 'blocks') {
|
||||
if (field?.blocks?.length) {
|
||||
field.blockReferences = new Array(field.blocks.length)
|
||||
for (let i = 0; i < field.blocks.length; i++) {
|
||||
const block = field.blocks[i]
|
||||
if (!block) {
|
||||
continue
|
||||
}
|
||||
deduplicateBlock({ block, config, silent })
|
||||
field.blockReferences[i] = block.slug as BlockSlug
|
||||
}
|
||||
field.blocks = []
|
||||
}
|
||||
|
||||
if (debug && !silent) {
|
||||
console.log('migrated field', field)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
return config
|
||||
}
|
||||
|
||||
export const deduplicateBlock = ({
|
||||
block: dedupedBlock,
|
||||
config,
|
||||
silent,
|
||||
}: {
|
||||
block: Block
|
||||
config: Config
|
||||
silent?: boolean
|
||||
}) => {
|
||||
/**
|
||||
* Will be true if a block with the same slug is found.
|
||||
*/
|
||||
let alreadyDeduplicated = false
|
||||
|
||||
if (config?.blocks?.length) {
|
||||
for (const existingBlock of config.blocks) {
|
||||
if (existingBlock.slug === dedupedBlock.slug) {
|
||||
alreadyDeduplicated = true
|
||||
|
||||
// Check if the fields are the same
|
||||
const jsonExistingBlock = JSON.stringify(existingBlock, null, 2)
|
||||
const jsonBlockToDeduplicate = JSON.stringify(dedupedBlock, null, 2)
|
||||
if (jsonExistingBlock !== jsonBlockToDeduplicate) {
|
||||
console.error('Block with the same slug but different fields found', {
|
||||
slug: dedupedBlock.slug,
|
||||
existingBlock: jsonExistingBlock,
|
||||
dedupedBlock: jsonBlockToDeduplicate,
|
||||
})
|
||||
throw new Error('Block with the same slug but different fields found')
|
||||
}
|
||||
if (
|
||||
// Object reference check for just the block fields - it's more likely that top-level block keys have been spread
|
||||
!Object.is(existingBlock.fields, dedupedBlock.fields) &&
|
||||
!silent
|
||||
) {
|
||||
// only throw warning:
|
||||
console.warn(
|
||||
'Block with the same slug but different fields found. JSON is different, but object references are equal. Please manually verify that things like functions passed to the blocks behave the same way.',
|
||||
{
|
||||
slug: dedupedBlock.slug,
|
||||
existingBlock: JSON.stringify(existingBlock, null, 2),
|
||||
dedupedBlock: JSON.stringify(dedupedBlock, null, 2),
|
||||
},
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!alreadyDeduplicated) {
|
||||
if (!config.blocks) {
|
||||
config.blocks = []
|
||||
}
|
||||
config.blocks.push(dedupedBlock)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user