From e87521a3762040e7b8be0f53ecc0ce6c9554bc7d Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Thu, 3 Apr 2025 12:27:14 -0400 Subject: [PATCH] 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 Co-authored-by: Alessio Gravili --- docs/configuration/collections.mdx | 50 +-- packages/payload/src/admin/forms/Form.ts | 22 +- .../src/features/upload/server/validate.ts | 1 - packages/richtext-lexical/src/index.ts | 4 +- packages/ui/src/fields/Array/index.tsx | 30 +- packages/ui/src/fields/Blocks/index.tsx | 4 +- packages/ui/src/forms/Form/fieldReducer.ts | 73 +---- packages/ui/src/forms/Form/index.tsx | 2 +- .../ui/src/forms/Form/mergeServerFormState.ts | 3 +- .../addFieldStatePromise.ts | 65 ++-- .../forms/fieldSchemasToFormState/index.tsx | 4 + .../fieldSchemasToFormState/iterateFields.ts | 4 + .../fieldSchemasToFormState/renderField.tsx | 291 +++++++++++------- .../forms/fieldSchemasToFormState/types.ts | 3 + packages/ui/src/providers/ListQuery/types.ts | 2 +- packages/ui/src/utilities/buildFormState.ts | 2 + .../migrations/20250328_185055.ts | 2 +- .../collections/Posts/ArrayRowLabel.tsx | 5 + test/form-state/collections/Posts/index.ts | 5 + test/form-state/e2e.spec.ts | 99 ++++-- test/form-state/int.spec.ts | 90 +++++- test/helpers/e2e/assertRequestBody.ts | 25 +- test/helpers/e2e/assertResponseBody.ts | 86 ++++++ test/queues/config.ts | 4 +- test/queues/int.spec.ts | 2 +- tsconfig.base.json | 2 +- 26 files changed, 585 insertions(+), 295 deletions(-) create mode 100644 test/form-state/collections/Posts/ArrayRowLabel.tsx create mode 100644 test/helpers/e2e/assertResponseBody.ts diff --git a/docs/configuration/collections.mdx b/docs/configuration/collections.mdx index 9e804750b9..bbea44a364 100644 --- a/docs/configuration/collections.mdx +++ b/docs/configuration/collections.mdx @@ -60,31 +60,31 @@ export const Posts: CollectionConfig = { The following options are available: -| Option | Description | -| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `admin` | The configuration options for the Admin Panel. [More details](#admin-options). | -| `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). | -| `auth` | Specify options if you would like this Collection to feature authentication. [More details](../authentication/overview). | -| `custom` | Extension point for adding custom data (e.g. for plugins) | -| `disableDuplicate` | When true, do not show the "Duplicate" button while editing documents within this Collection and prevent `duplicate` from all APIs. | -| `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. | -| `dbName` | Custom table or Collection name depending on the Database Adapter. Auto-generated from slug if not defined. | -| `endpoints` | Add custom routes to the REST API. Set to `false` to disable routes. [More details](../rest-api/overview#custom-endpoints). | -| `fields` \* | Array of field types that will determine the structure and functionality of the data stored within this Collection. [More details](../fields/overview). | -| `graphQL` | Manage GraphQL-related properties for this collection. [More](#graphql) | -| `hooks` | Entry point for Hooks. [More details](../hooks/overview#collection-hooks). | -| `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. | -| `labels` | Singular and plural labels for use in identifying this Collection throughout Payload. Auto-generated from slug if not defined. | -| `enableQueryPresets` | Enable query presets for this Collection. [More details](../query-presets/overview). | -| `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). | -| `slug` \* | Unique, URL-friendly string that will act as an identifier for this Collection. | -| `timestamps` | Set to false to disable documents' automatically generated `createdAt` and `updatedAt` timestamps. | -| `typescript` | An object with property `interface` as the text used in schema generation. Auto-generated from slug if not defined. | -| `upload` | Specify options if you would like this Collection to support file uploads. For more, consult the [Uploads](../upload/overview) documentation. | -| `versions` | Set to true to enable default options, or configure with object properties. [More details](../versions/overview#collection-config). | -| `defaultPopulate` | Specify which fields to select when this Collection is populated from another document. [More Details](../queries/select#defaultpopulate-collection-config-property). | -| `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. | -| `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 | +| Option | Description | +| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `admin` | The configuration options for the Admin Panel. [More details](#admin-options). | +| `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). | +| `auth` | Specify options if you would like this Collection to feature authentication. [More details](../authentication/overview). | +| `custom` | Extension point for adding custom data (e.g. for plugins) | +| `disableDuplicate` | When true, do not show the "Duplicate" button while editing documents within this Collection and prevent `duplicate` from all APIs. | +| `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. | +| `dbName` | Custom table or Collection name depending on the Database Adapter. Auto-generated from slug if not defined. | +| `endpoints` | Add custom routes to the REST API. Set to `false` to disable routes. [More details](../rest-api/overview#custom-endpoints). | +| `fields` \* | Array of field types that will determine the structure and functionality of the data stored within this Collection. [More details](../fields/overview). | +| `graphQL` | Manage GraphQL-related properties for this collection. [More](#graphql) | +| `hooks` | Entry point for Hooks. [More details](../hooks/overview#collection-hooks). | +| `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. | +| `labels` | Singular and plural labels for use in identifying this Collection throughout Payload. Auto-generated from slug if not defined. | +| `enableQueryPresets` | Enable query presets for this Collection. [More details](../query-presets/overview). | +| `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). | +| `slug` \* | Unique, URL-friendly string that will act as an identifier for this Collection. | +| `timestamps` | Set to false to disable documents' automatically generated `createdAt` and `updatedAt` timestamps. | +| `typescript` | An object with property `interface` as the text used in schema generation. Auto-generated from slug if not defined. | +| `upload` | Specify options if you would like this Collection to support file uploads. For more, consult the [Uploads](../upload/overview) documentation. | +| `versions` | Set to true to enable default options, or configure with object properties. [More details](../versions/overview#collection-config). | +| `defaultPopulate` | Specify which fields to select when this Collection is populated from another document. [More Details](../queries/select#defaultpopulate-collection-config-property). | +| `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. | +| `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 | _\* An asterisk denotes that a property is required._ diff --git a/packages/payload/src/admin/forms/Form.ts b/packages/payload/src/admin/forms/Form.ts index 419087a6ec..88274b70b8 100644 --- a/packages/payload/src/admin/forms/Form.ts +++ b/packages/payload/src/admin/forms/Form.ts @@ -13,8 +13,12 @@ export type Data = { export type Row = { blockType?: string collapsed?: boolean + customComponents?: { + RowLabel?: React.ReactNode + } id: string isLoading?: boolean + lastRenderedPath?: string } export type FilterOptionsResult = { @@ -34,7 +38,6 @@ export type FieldState = { Error?: React.ReactNode Field?: React.ReactNode Label?: React.ReactNode - RowLabels?: React.ReactNode[] } disableFormData?: boolean errorMessage?: string @@ -46,8 +49,16 @@ export type FieldState = { fieldSchema?: Field filterOptions?: FilterOptionsResult initialValue?: unknown + /** + * The path of the field when its custom components were last rendered. + * This is used to denote if a field has been rendered, and if so, + * what path it was rendered under last. + * + * If this path is undefined, or, if it is different + * from the current path of a given field, the field's components will be re-rendered. + */ + lastRenderedPath?: string passesCondition?: boolean - requiresRender?: boolean rows?: Row[] /** * The `serverPropsToIgnore` obj is used to prevent the various properties from being overridden across form state requests. @@ -95,6 +106,13 @@ export type BuildFormStateArgs = { */ language?: keyof SupportedLanguages locale?: string + /** + * If true, will not render RSCs and instead return a simple string in their place. + * This is useful for environments that lack RSC support, such as Jest. + * Form state can still be built, but any server components will be omitted. + * @default false + */ + mockRSCs?: boolean operation?: 'create' | 'update' /* If true, will render field components within their state object diff --git a/packages/richtext-lexical/src/features/upload/server/validate.ts b/packages/richtext-lexical/src/features/upload/server/validate.ts index bbbdbca49e..ac475d1acb 100644 --- a/packages/richtext-lexical/src/features/upload/server/validate.ts +++ b/packages/richtext-lexical/src/features/upload/server/validate.ts @@ -46,7 +46,6 @@ export const uploadValidation = ( const result = await fieldSchemasToFormState({ id, collectionSlug: node.relationTo, - data: node?.fields ?? {}, documentData: data, fields: collection.fields, diff --git a/packages/richtext-lexical/src/index.ts b/packages/richtext-lexical/src/index.ts index 78f6b95551..4e0fd0083d 100644 --- a/packages/richtext-lexical/src/index.ts +++ b/packages/richtext-lexical/src/index.ts @@ -901,12 +901,12 @@ export { HTMLConverterFeature, type HTMLConverterFeatureProps, } from './features/converters/lexicalToHtml_deprecated/index.js' -export { DebugJsxConverterFeature } from './features/debug/jsxConverter/server/index.js' export { convertLexicalToMarkdown } from './features/converters/lexicalToMarkdown/index.js' export { convertMarkdownToLexical } from './features/converters/markdownToLexical/index.js' - export { getPayloadPopulateFn } from './features/converters/utilities/payloadPopulateFn.js' + export { getRestPopulateFn } from './features/converters/utilities/restPopulateFn.js' +export { DebugJsxConverterFeature } from './features/debug/jsxConverter/server/index.js' export { TestRecorderFeature } from './features/debug/testRecorder/server/index.js' export { TreeViewFeature } from './features/debug/treeView/server/index.js' export { EXPERIMENTAL_TableFeature } from './features/experimental_table/server/index.js' diff --git a/packages/ui/src/fields/Array/index.tsx b/packages/ui/src/fields/Array/index.tsx index a230a93c82..5c3730e4c3 100644 --- a/packages/ui/src/fields/Array/index.tsx +++ b/packages/ui/src/fields/Array/index.tsx @@ -110,10 +110,10 @@ export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => { ) const { - customComponents: { AfterInput, BeforeInput, Description, Error, Label, RowLabels } = {}, + customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, disabled, errorPaths, - rows: rowsData = [], + rows = [], showError, valid, value, @@ -173,12 +173,12 @@ export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => { (collapsed: boolean) => { const { collapsedIDs, updatedRows } = toggleAllRows({ collapsed, - rows: rowsData, + rows, }) setDocFieldPreferences(path, { collapsed: collapsedIDs }) dispatchFields({ type: 'SET_ALL_ROWS_COLLAPSED', path, updatedRows }) }, - [dispatchFields, path, rowsData, setDocFieldPreferences], + [dispatchFields, path, rows, setDocFieldPreferences], ) const setCollapse = useCallback( @@ -186,22 +186,22 @@ export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => { const { collapsedIDs, updatedRows } = extractRowsAndCollapsedIDs({ collapsed, rowID, - rows: rowsData, + rows, }) dispatchFields({ type: 'SET_ROW_COLLAPSED', path, updatedRows }) setDocFieldPreferences(path, { collapsed: collapsedIDs }) }, - [dispatchFields, path, rowsData, setDocFieldPreferences], + [dispatchFields, path, rows, setDocFieldPreferences], ) - const hasMaxRows = maxRows && rowsData.length >= maxRows + const hasMaxRows = maxRows && rows.length >= maxRows const fieldErrorCount = errorPaths.length const fieldHasErrors = submitted && errorPaths.length > 0 - const showRequired = (readOnly || disabled) && rowsData.length === 0 - const showMinRows = rowsData.length < minRows || (required && rowsData.length === 0) + const showRequired = (readOnly || disabled) && rows.length === 0 + const showMinRows = rows.length < minRows || (required && rows.length === 0) return (
{ )}
- {rowsData?.length > 0 && ( + {rows?.length > 0 && (