Skip to content

Commit e87521a

Browse files
jacobsfletchjmikrutAlessioGr
authored
perf(ui): significantly optimize form state component rendering, up to 96% smaller and 75% faster (#11946)
Significantly optimizes the component rendering strategy within the form state endpoint by precisely rendering only the fields that require it. This cuts down on server processing and network response sizes when invoking form state requests **that manipulate array and block rows which contain server components**, such as rich text fields, custom row labels, etc. (results listed below). Here's a breakdown of the issue: Previously, when manipulating array and block fields, _all_ rows would render any server components that might exist within them, including rich text fields. This means that subsequent changes to these fields would potentially _re-render_ those same components even if they don't require it. For example, if you have an array field with a rich text field within it, adding the first row would cause the rich text field to render, which is expected. However, when you add a second row, the rich text field within the first row would render again unnecessarily along with the new row. This is especially noticeable for fields with many rows, where every single row processes its server components and returns RSC data. And this does not only affect nested rich text fields, but any custom component defined on the field level, as these are handled in the same way. The reason this was necessary in the first place was to ensure that the server components receive the proper data when they are rendered, such as the row index and the row's data. Changing one of these rows could cause the server component to receive the wrong data if it was not freshly rendered. While this is still a requirement that rows receive up-to-date props, it is no longer necessary to render everything. Here's a breakdown of the actual fix: This change ensures that only the fields that are actually being manipulated will be rendered, rather than all rows. The existing rows will remain in memory on the client, while the newly rendered components will return from the server. For example, if you add a new row to an array field, only the new row will render its server components. To do this, we send the path of the field that is being manipulated to the server. The server can then use this path to determine for itself which fields have already been rendered and which ones need required rendering. ## Results The following results were gathered by booting up the `form-state` test suite and seeding 100 array rows, each containing a rich text field. To invoke a form state request, we navigate to a document within the "posts" collection, then add a new array row to the list. The result is then saved to the file system for comparison. | Test Suite | Collection | Number of Rows | Before | After | Percentage Change | |------|------|---------|--------|--------|--------| | `form-state` | `posts` | 101 | 1.9MB / 266ms | 80KB / 70ms | ~96% smaller / ~75% faster | --------- Co-authored-by: James <james@trbl.design> Co-authored-by: Alessio Gravili <alessio@gravili.de>
1 parent 8880d70 commit e87521a

File tree

26 files changed

+572
-282
lines changed

26 files changed

+572
-282
lines changed

docs/configuration/collections.mdx

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -60,31 +60,31 @@ export const Posts: CollectionConfig = {
6060

6161
The following options are available:
6262

63-
| Option | Description |
64-
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
65-
| `admin` | The configuration options for the Admin Panel. [More details](#admin-options). |
66-
| `access` | Provide Access Control functions to define exactly who should be able to do what with Documents in this Collection. [More details](../access-control/collections). |
67-
| `auth` | Specify options if you would like this Collection to feature authentication. [More details](../authentication/overview). |
68-
| `custom` | Extension point for adding custom data (e.g. for plugins) |
69-
| `disableDuplicate` | When true, do not show the "Duplicate" button while editing documents within this Collection and prevent `duplicate` from all APIs. |
70-
| `defaultSort` | Pass a top-level field to sort by default in the Collection List View. Prefix the name of the field with a minus symbol ("-") to sort in descending order. Multiple fields can be specified by using a string array. |
71-
| `dbName` | Custom table or Collection name depending on the Database Adapter. Auto-generated from slug if not defined. |
72-
| `endpoints` | Add custom routes to the REST API. Set to `false` to disable routes. [More details](../rest-api/overview#custom-endpoints). |
73-
| `fields` \* | Array of field types that will determine the structure and functionality of the data stored within this Collection. [More details](../fields/overview). |
74-
| `graphQL` | Manage GraphQL-related properties for this collection. [More](#graphql) |
75-
| `hooks` | Entry point for Hooks. [More details](../hooks/overview#collection-hooks). |
76-
| `orderable` | If true, enables custom ordering for the collection, and documents can be reordered via drag and drop. Uses [fractional indexing](https://observablehq.com/@dgreensp/implementing-fractional-indexing) for efficient reordering. |
77-
| `labels` | Singular and plural labels for use in identifying this Collection throughout Payload. Auto-generated from slug if not defined. |
78-
| `enableQueryPresets` | Enable query presets for this Collection. [More details](../query-presets/overview). |
79-
| `lockDocuments` | Enables or disables document locking. By default, document locking is enabled. Set to an object to configure, or set to `false` to disable locking. [More details](../admin/locked-documents). |
80-
| `slug` \* | Unique, URL-friendly string that will act as an identifier for this Collection. |
81-
| `timestamps` | Set to false to disable documents' automatically generated `createdAt` and `updatedAt` timestamps. |
82-
| `typescript` | An object with property `interface` as the text used in schema generation. Auto-generated from slug if not defined. |
83-
| `upload` | Specify options if you would like this Collection to support file uploads. For more, consult the [Uploads](../upload/overview) documentation. |
84-
| `versions` | Set to true to enable default options, or configure with object properties. [More details](../versions/overview#collection-config). |
85-
| `defaultPopulate` | Specify which fields to select when this Collection is populated from another document. [More Details](../queries/select#defaultpopulate-collection-config-property). |
86-
| `indexes` | Define compound indexes for this collection. This can be used to either speed up querying/sorting by 2 or more fields at the same time or to ensure uniqueness between several fields. |
87-
| `forceSelect` | Specify which fields should be selected always, regardless of the `select` query which can be useful that the field exists for access control / hooks |
63+
| Option | Description |
64+
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
65+
| `admin` | The configuration options for the Admin Panel. [More details](#admin-options). |
66+
| `access` | Provide Access Control functions to define exactly who should be able to do what with Documents in this Collection. [More details](../access-control/collections). |
67+
| `auth` | Specify options if you would like this Collection to feature authentication. [More details](../authentication/overview). |
68+
| `custom` | Extension point for adding custom data (e.g. for plugins) |
69+
| `disableDuplicate` | When true, do not show the "Duplicate" button while editing documents within this Collection and prevent `duplicate` from all APIs. |
70+
| `defaultSort` | Pass a top-level field to sort by default in the Collection List View. Prefix the name of the field with a minus symbol ("-") to sort in descending order. Multiple fields can be specified by using a string array. |
71+
| `dbName` | Custom table or Collection name depending on the Database Adapter. Auto-generated from slug if not defined. |
72+
| `endpoints` | Add custom routes to the REST API. Set to `false` to disable routes. [More details](../rest-api/overview#custom-endpoints). |
73+
| `fields` \* | Array of field types that will determine the structure and functionality of the data stored within this Collection. [More details](../fields/overview). |
74+
| `graphQL` | Manage GraphQL-related properties for this collection. [More](#graphql) |
75+
| `hooks` | Entry point for Hooks. [More details](../hooks/overview#collection-hooks). |
76+
| `orderable` | If true, enables custom ordering for the collection, and documents can be reordered via drag and drop. Uses [fractional indexing](https://observablehq.com/@dgreensp/implementing-fractional-indexing) for efficient reordering. |
77+
| `labels` | Singular and plural labels for use in identifying this Collection throughout Payload. Auto-generated from slug if not defined. |
78+
| `enableQueryPresets` | Enable query presets for this Collection. [More details](../query-presets/overview). |
79+
| `lockDocuments` | Enables or disables document locking. By default, document locking is enabled. Set to an object to configure, or set to `false` to disable locking. [More details](../admin/locked-documents). |
80+
| `slug` \* | Unique, URL-friendly string that will act as an identifier for this Collection. |
81+
| `timestamps` | Set to false to disable documents' automatically generated `createdAt` and `updatedAt` timestamps. |
82+
| `typescript` | An object with property `interface` as the text used in schema generation. Auto-generated from slug if not defined. |
83+
| `upload` | Specify options if you would like this Collection to support file uploads. For more, consult the [Uploads](../upload/overview) documentation. |
84+
| `versions` | Set to true to enable default options, or configure with object properties. [More details](../versions/overview#collection-config). |
85+
| `defaultPopulate` | Specify which fields to select when this Collection is populated from another document. [More Details](../queries/select#defaultpopulate-collection-config-property). |
86+
| `indexes` | Define compound indexes for this collection. This can be used to either speed up querying/sorting by 2 or more fields at the same time or to ensure uniqueness between several fields. |
87+
| `forceSelect` | Specify which fields should be selected always, regardless of the `select` query which can be useful that the field exists for access control / hooks |
8888

8989
_\* An asterisk denotes that a property is required._
9090

packages/payload/src/admin/forms/Form.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,12 @@ export type Data = {
1313
export type Row = {
1414
blockType?: string
1515
collapsed?: boolean
16+
customComponents?: {
17+
RowLabel?: React.ReactNode
18+
}
1619
id: string
1720
isLoading?: boolean
21+
lastRenderedPath?: string
1822
}
1923

2024
export type FilterOptionsResult = {
@@ -34,7 +38,6 @@ export type FieldState = {
3438
Error?: React.ReactNode
3539
Field?: React.ReactNode
3640
Label?: React.ReactNode
37-
RowLabels?: React.ReactNode[]
3841
}
3942
disableFormData?: boolean
4043
errorMessage?: string
@@ -46,8 +49,16 @@ export type FieldState = {
4649
fieldSchema?: Field
4750
filterOptions?: FilterOptionsResult
4851
initialValue?: unknown
52+
/**
53+
* The path of the field when its custom components were last rendered.
54+
* This is used to denote if a field has been rendered, and if so,
55+
* what path it was rendered under last.
56+
*
57+
* If this path is undefined, or, if it is different
58+
* from the current path of a given field, the field's components will be re-rendered.
59+
*/
60+
lastRenderedPath?: string
4961
passesCondition?: boolean
50-
requiresRender?: boolean
5162
rows?: Row[]
5263
/**
5364
* The `serverPropsToIgnore` obj is used to prevent the various properties from being overridden across form state requests.
@@ -95,6 +106,13 @@ export type BuildFormStateArgs = {
95106
*/
96107
language?: keyof SupportedLanguages
97108
locale?: string
109+
/**
110+
* If true, will not render RSCs and instead return a simple string in their place.
111+
* This is useful for environments that lack RSC support, such as Jest.
112+
* Form state can still be built, but any server components will be omitted.
113+
* @default false
114+
*/
115+
mockRSCs?: boolean
98116
operation?: 'create' | 'update'
99117
/*
100118
If true, will render field components within their state object

packages/richtext-lexical/src/features/upload/server/validate.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@ export const uploadValidation = (
4646
const result = await fieldSchemasToFormState({
4747
id,
4848
collectionSlug: node.relationTo,
49-
5049
data: node?.fields ?? {},
5150
documentData: data,
5251
fields: collection.fields,

packages/richtext-lexical/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -901,12 +901,12 @@ export {
901901
HTMLConverterFeature,
902902
type HTMLConverterFeatureProps,
903903
} from './features/converters/lexicalToHtml_deprecated/index.js'
904-
export { DebugJsxConverterFeature } from './features/debug/jsxConverter/server/index.js'
905904
export { convertLexicalToMarkdown } from './features/converters/lexicalToMarkdown/index.js'
906905
export { convertMarkdownToLexical } from './features/converters/markdownToLexical/index.js'
907-
908906
export { getPayloadPopulateFn } from './features/converters/utilities/payloadPopulateFn.js'
907+
909908
export { getRestPopulateFn } from './features/converters/utilities/restPopulateFn.js'
909+
export { DebugJsxConverterFeature } from './features/debug/jsxConverter/server/index.js'
910910
export { TestRecorderFeature } from './features/debug/testRecorder/server/index.js'
911911
export { TreeViewFeature } from './features/debug/treeView/server/index.js'
912912
export { EXPERIMENTAL_TableFeature } from './features/experimental_table/server/index.js'

packages/ui/src/fields/Array/index.tsx

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -110,10 +110,10 @@ export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => {
110110
)
111111

112112
const {
113-
customComponents: { AfterInput, BeforeInput, Description, Error, Label, RowLabels } = {},
113+
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
114114
disabled,
115115
errorPaths,
116-
rows: rowsData = [],
116+
rows = [],
117117
showError,
118118
valid,
119119
value,
@@ -173,35 +173,35 @@ export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => {
173173
(collapsed: boolean) => {
174174
const { collapsedIDs, updatedRows } = toggleAllRows({
175175
collapsed,
176-
rows: rowsData,
176+
rows,
177177
})
178178
setDocFieldPreferences(path, { collapsed: collapsedIDs })
179179
dispatchFields({ type: 'SET_ALL_ROWS_COLLAPSED', path, updatedRows })
180180
},
181-
[dispatchFields, path, rowsData, setDocFieldPreferences],
181+
[dispatchFields, path, rows, setDocFieldPreferences],
182182
)
183183

184184
const setCollapse = useCallback(
185185
(rowID: string, collapsed: boolean) => {
186186
const { collapsedIDs, updatedRows } = extractRowsAndCollapsedIDs({
187187
collapsed,
188188
rowID,
189-
rows: rowsData,
189+
rows,
190190
})
191191

192192
dispatchFields({ type: 'SET_ROW_COLLAPSED', path, updatedRows })
193193
setDocFieldPreferences(path, { collapsed: collapsedIDs })
194194
},
195-
[dispatchFields, path, rowsData, setDocFieldPreferences],
195+
[dispatchFields, path, rows, setDocFieldPreferences],
196196
)
197197

198-
const hasMaxRows = maxRows && rowsData.length >= maxRows
198+
const hasMaxRows = maxRows && rows.length >= maxRows
199199

200200
const fieldErrorCount = errorPaths.length
201201
const fieldHasErrors = submitted && errorPaths.length > 0
202202

203-
const showRequired = (readOnly || disabled) && rowsData.length === 0
204-
const showMinRows = rowsData.length < minRows || (required && rowsData.length === 0)
203+
const showRequired = (readOnly || disabled) && rows.length === 0
204+
const showMinRows = rows.length < minRows || (required && rows.length === 0)
205205

206206
return (
207207
<div
@@ -242,7 +242,7 @@ export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => {
242242
<ErrorPill count={fieldErrorCount} i18n={i18n} withMessage />
243243
)}
244244
</div>
245-
{rowsData?.length > 0 && (
245+
{rows?.length > 0 && (
246246
<ul className={`${baseClass}__header-actions`}>
247247
<li>
248248
<button
@@ -272,13 +272,13 @@ export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => {
272272
</header>
273273
<NullifyLocaleField fieldValue={value} localized={localized} path={path} />
274274
{BeforeInput}
275-
{(rowsData?.length > 0 || (!valid && (showRequired || showMinRows))) && (
275+
{(rows?.length > 0 || (!valid && (showRequired || showMinRows))) && (
276276
<DraggableSortable
277277
className={`${baseClass}__draggable-rows`}
278-
ids={rowsData.map((row) => row.id)}
278+
ids={rows.map((row) => row.id)}
279279
onDragEnd={({ moveFromIndex, moveToIndex }) => moveRow(moveFromIndex, moveToIndex)}
280280
>
281-
{rowsData.map((rowData, i) => {
281+
{rows.map((rowData, i) => {
282282
const { id: rowID, isLoading } = rowData
283283

284284
const rowPath = `${path}.${i}`
@@ -297,7 +297,7 @@ export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => {
297297
<ArrayRow
298298
{...draggableSortableItemProps}
299299
addRow={addRow}
300-
CustomRowLabel={RowLabels?.[i]}
300+
CustomRowLabel={rows?.[i]?.customComponents?.RowLabel}
301301
duplicateRow={duplicateRow}
302302
errorCount={rowErrorCount}
303303
fields={fields}
@@ -313,7 +313,7 @@ export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => {
313313
readOnly={readOnly || disabled}
314314
removeRow={removeRow}
315315
row={rowData}
316-
rowCount={rowsData?.length}
316+
rowCount={rows?.length}
317317
rowIndex={i}
318318
schemaPath={schemaPath}
319319
setCollapse={setCollapse}

packages/ui/src/fields/Blocks/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ const BlocksFieldComponent: BlocksFieldClientComponent = (props) => {
9898
)
9999

100100
const {
101-
customComponents: { AfterInput, BeforeInput, Description, Error, Label, RowLabels } = {},
101+
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
102102
disabled,
103103
errorPaths,
104104
rows = [],
@@ -293,7 +293,7 @@ const BlocksFieldComponent: BlocksFieldClientComponent = (props) => {
293293
hasMaxRows={hasMaxRows}
294294
isLoading={isLoading}
295295
isSortable={isSortable}
296-
Label={RowLabels?.[i]}
296+
Label={rows?.[i]?.customComponents?.RowLabel}
297297
labels={labels}
298298
moveRow={moveRow}
299299
parentPath={path}

0 commit comments

Comments
 (0)