From 31ae27b67d73aac47b10eeacfce5a79734beaad9 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Tue, 14 Jan 2025 10:45:54 -0500 Subject: [PATCH] perf: significantly reduce form state response size by up to 3x (#9388) This significantly optimizes the form state, reducing its size by up to more than 3x and improving overall response times. This change also has rolling effects on initial page size as well, where the initial state for the entire form is sent through the request. To achieve this, we do the following: - Remove `$undefined` strings that are potentially attached to properties like `value`, `initialValue`, `fieldSchema`, etc. - Remove unnecessary properties like empty `errorPaths` arrays and empty `customComponents` objects, which only need to exist if used - Remove unnecessary properties like `valid`, `passesCondition`, etc. which only need to be returned if explicitly `false` - Remove unused properties like `isSidebar`, which simply don't need to exist at all, as they can be easily calculated during render ## Results The following results were gathered by booting up each test suite listed below using the existing seed data, navigating to a document in the relevant collection, then typing a single letter into the noted field in order to invoke new form-state. The result is then saved to the file system for comparison. | Test Suite | Collection | Field | Before | After | Percentage Change | |------|------|---------|--------|--------|--------| | `field-perf` | `blocks-collection` | `layout.0.field1` | 227kB | 110 kB | ~52% smaller | | `fields` | `array-fields` | `items.0.text` | 14 kB | 4 kB | ~72% smaller | | `fields` | `block-fields` | `blocks.0.richText` | 25 kB | 14 kB | ~44% smaller | --- .../src/utilities/initPage/handleAdminPage.ts | 2 +- packages/payload/src/admin/forms/Field.ts | 2 +- packages/payload/src/admin/forms/Form.ts | 7 +- packages/payload/src/admin/types.ts | 6 + packages/payload/src/config/types.ts | 3 +- .../BulkUpload/FormsManager/reducer.ts | 2 +- packages/ui/src/forms/Form/index.tsx | 6 +- .../addFieldStatePromise.ts | 176 +++++++++++------- .../fieldSchemasToFormState/renderField.tsx | 47 +++-- test/buildConfigWithDefaults.ts | 5 + tsconfig.base.json | 2 +- 11 files changed, 160 insertions(+), 98 deletions(-) diff --git a/packages/next/src/utilities/initPage/handleAdminPage.ts b/packages/next/src/utilities/initPage/handleAdminPage.ts index 9ad1a8eb7..c75b4868d 100644 --- a/packages/next/src/utilities/initPage/handleAdminPage.ts +++ b/packages/next/src/utilities/initPage/handleAdminPage.ts @@ -51,7 +51,7 @@ export function getRouteInfo({ globalConfig = config.globals.find((global) => global.slug === globalSlug) } - // If the collection is using a custom ID, we need to determine it's type + // If the collection is using a custom ID, we need to determine its type if (collectionConfig && payload) { if (payload.collections?.[collectionSlug]?.customIDType) { idType = payload.collections?.[collectionSlug].customIDType diff --git a/packages/payload/src/admin/forms/Field.ts b/packages/payload/src/admin/forms/Field.ts index 93c2e7fb4..d6fd4d626 100644 --- a/packages/payload/src/admin/forms/Field.ts +++ b/packages/payload/src/admin/forms/Field.ts @@ -18,7 +18,7 @@ import type { export type ClientFieldWithOptionalType = MarkOptional export type ClientComponentProps = { - customComponents: FormField['customComponents'] + customComponents?: FormField['customComponents'] field: ClientBlock | ClientField | ClientTab forceRender?: boolean permissions?: SanitizedFieldPermissions diff --git a/packages/payload/src/admin/forms/Form.ts b/packages/payload/src/admin/forms/Form.ts index 822176bd6..28dd8422d 100644 --- a/packages/payload/src/admin/forms/Form.ts +++ b/packages/payload/src/admin/forms/Form.ts @@ -45,14 +45,13 @@ export type FieldState = { */ fieldSchema?: Field filterOptions?: FilterOptionsResult - initialValue: unknown - isSidebar?: boolean + initialValue?: unknown passesCondition?: boolean requiresRender?: boolean rows?: Row[] - valid: boolean + valid?: boolean validate?: Validate - value: unknown + value?: unknown } export type FieldStateWithoutComponents = Omit diff --git a/packages/payload/src/admin/types.ts b/packages/payload/src/admin/types.ts index 315c99b74..5e313b09d 100644 --- a/packages/payload/src/admin/types.ts +++ b/packages/payload/src/admin/types.ts @@ -453,6 +453,12 @@ export type RenderedField = { Field: React.ReactNode indexPath?: string initialSchemaPath?: string + /** + * @deprecated + * This is a legacy property that will be removed in v4. + * Please use `fieldIsSidebar(field)` from `payload` instead. + * Or check `field.admin.position === 'sidebar'` directly. + */ isSidebar: boolean path: string schemaPath: string diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index 297ed004a..433865685 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -791,7 +791,7 @@ export type Config = { dependencies?: AdminDependencies /** * @deprecated - * This option is deprecated and will be removed in the next major version. + * This option is deprecated and will be removed in v4. * To disable the admin panel itself, delete your `/app/(payload)/admin` directory. * To disable all REST API and GraphQL endpoints, delete your `/app/(payload)/api` directory. * Note: If you've modified the default paths via `admin.routes`, delete those directories instead. @@ -803,7 +803,6 @@ export type Config = { * @default true */ autoGenerate?: boolean - /** The base directory for component paths starting with /. * * By default, this is process.cwd() diff --git a/packages/ui/src/elements/BulkUpload/FormsManager/reducer.ts b/packages/ui/src/elements/BulkUpload/FormsManager/reducer.ts index e7a2b78ce..543f3cdab 100644 --- a/packages/ui/src/elements/BulkUpload/FormsManager/reducer.ts +++ b/packages/ui/src/elements/BulkUpload/FormsManager/reducer.ts @@ -1,4 +1,4 @@ -import type { FormState } from 'payload' +import type { FormFieldWithoutComponents, FormState } from 'payload' export type State = { activeIndex: number diff --git a/packages/ui/src/forms/Form/index.tsx b/packages/ui/src/forms/Form/index.tsx index 5219d4abb..3c1dabe63 100644 --- a/packages/ui/src/forms/Form/index.tsx +++ b/packages/ui/src/forms/Form/index.tsx @@ -228,6 +228,7 @@ export const Form: React.FC = (props) => { // Execute server side validations if (Array.isArray(beforeSubmit)) { let revalidatedFormState: FormState + const serializableFields = deepCopyObjectSimpleWithoutReactComponents( contextRef.current.fields, ) @@ -242,7 +243,9 @@ export const Form: React.FC = (props) => { revalidatedFormState = result }, Promise.resolve()) - const isValid = Object.entries(revalidatedFormState).every(([, field]) => field.valid) + const isValid = Object.entries(revalidatedFormState).every( + ([, field]) => field.valid !== false, + ) if (!isValid) { setProcessing(false) @@ -277,6 +280,7 @@ export const Form: React.FC = (props) => { const serializableFields = deepCopyObjectSimpleWithoutReactComponents( contextRef.current.fields, ) + const data = reduceFieldsToValues(serializableFields, true) for (const [key, value] of Object.entries(overrides)) { diff --git a/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts b/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts index d0842b8e1..1022f95b3 100644 --- a/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts +++ b/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts @@ -4,6 +4,7 @@ import type { DocumentPreferences, Field, FieldSchemaMap, + FieldState, FormFieldWithoutComponents, FormState, FormStateWithoutComponents, @@ -139,14 +140,14 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom let fieldPermissions: SanitizedFieldPermissions = true - const fieldState: FormFieldWithoutComponents = { - errorPaths: [], - fieldSchema: includeSchema ? field : undefined, - initialValue: undefined, - isSidebar: fieldIsSidebar(field), - passesCondition, - valid: true, - value: undefined, + const fieldState: FieldState = {} + + if (passesCondition === false) { + fieldState.passesCondition = false + } + + if (includeSchema) { + fieldState.fieldSchema = field } if (fieldAffectsData(field) && !fieldIsHiddenOrDisabled(field)) { @@ -213,6 +214,10 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom addErrorPathToParentArg(errorPath) } + if (!fieldState.errorPaths) { + fieldState.errorPaths = [] + } + if (!fieldState.errorPaths.includes(errorPath)) { fieldState.errorPaths.push(errorPath) fieldState.valid = false @@ -223,8 +228,6 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom fieldState.errorMessage = validationResult fieldState.valid = false addErrorPathToParent(path) - } else { - fieldState.valid = true } switch (field.type) { @@ -237,14 +240,16 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom row.id = row?.id || new ObjectId().toHexString() if (!omitParents && (!filter || filter(args))) { - state[parentPath + '.id'] = { - fieldSchema: includeSchema - ? field.fields.find((field) => fieldIsID(field)) - : undefined, + const idKey = parentPath + '.id' + + state[idKey] = { initialValue: row.id, - valid: true, value: row.id, } + + if (includeSchema) { + state[idKey].fieldSchema = field.fields.find((field) => fieldIsID(field)) + } } acc.promises.push( @@ -280,50 +285,58 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom }), ) - const previousRows = previousFormState?.[path]?.rows || [] - const collapsedRowIDsFromPrefs = preferences?.fields?.[path]?.collapsed + if (!acc.rows) { + acc.rows = [] + } acc.rows.push({ id: row.id, - collapsed: (() => { - // First, check if `previousFormState` has a matching row - const previousRow = previousRows.find((prevRow) => prevRow.id === row.id) - if (previousRow?.collapsed !== undefined) { - return previousRow.collapsed - } - - // If previousFormState is undefined, check preferences - if (collapsedRowIDsFromPrefs !== undefined) { - return collapsedRowIDsFromPrefs.includes(row.id) // Check if collapsed in preferences - } - - // If neither exists, fallback to `field.admin.initCollapsed` - return field.admin.initCollapsed - })(), }) + const previousRows = previousFormState?.[path]?.rows || [] + const collapsedRowIDsFromPrefs = preferences?.fields?.[path]?.collapsed + + const collapsed = (() => { + // First, check if `previousFormState` has a matching row + const previousRow = previousRows.find((prevRow) => prevRow.id === row.id) + if (previousRow?.collapsed !== undefined) { + return previousRow.collapsed + } + + // If previousFormState is undefined, check preferences + if (collapsedRowIDsFromPrefs !== undefined) { + return collapsedRowIDsFromPrefs.includes(row.id) // Check if collapsed in preferences + } + + // If neither exists, fallback to `field.admin.initCollapsed` + return field.admin.initCollapsed + })() + + if (collapsed) { + acc.rows[acc.rows.length - 1].collapsed = collapsed + } + return acc }, { promises: [], - rows: [], + rows: undefined, }, ) // Wait for all promises and update fields with the results await Promise.all(promises) - fieldState.rows = rows + if (rows) { + fieldState.rows = rows + } // Unset requiresRender // so it will be removed from form state fieldState.requiresRender = false // Add values to field state - if (data[field.name] === null) { - fieldState.value = null - fieldState.initialValue = null - } else { + if (data[field.name] !== null) { fieldState.value = forceFullValue ? arrayValue : arrayValue.length fieldState.initialValue = forceFullValue ? arrayValue : arrayValue.length @@ -359,35 +372,48 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom row.id = row?.id || new ObjectId().toHexString() if (!omitParents && (!filter || filter(args))) { - state[parentPath + '.id'] = { - fieldSchema: includeSchema - ? block.fields.find((blockField) => fieldIsID(blockField)) - : undefined, + // Handle block `id` field + const idKey = parentPath + '.id' + + state[idKey] = { initialValue: row.id, - valid: true, value: row.id, } - state[parentPath + '.blockType'] = { - fieldSchema: includeSchema - ? block.fields.find( - (blockField) => 'name' in blockField && blockField.name === 'blockType', - ) - : undefined, + if (includeSchema) { + state[idKey].fieldSchema = includeSchema + ? block.fields.find((blockField) => fieldIsID(blockField)) + : undefined + } + + // Handle `blockType` field + const fieldKey = parentPath + '.blockType' + + state[fieldKey] = { initialValue: row.blockType, - valid: true, value: row.blockType, } - state[parentPath + '.blockName'] = { - fieldSchema: includeSchema - ? block.fields.find( - (blockField) => 'name' in blockField && blockField.name === 'blockName', - ) - : undefined, - initialValue: row.blockName, - valid: true, - value: row.blockName, + if (includeSchema) { + state[fieldKey].fieldSchema = block.fields.find( + (blockField) => 'name' in blockField && blockField.name === 'blockType', + ) + } + + // Handle `blockName` field + const blockNameKey = parentPath + '.blockName' + + state[blockNameKey] = {} + + if (row.blockName) { + state[blockNameKey].initialValue = row.blockName + state[blockNameKey].value = row.blockName + } + + if (includeSchema) { + state[blockNameKey].fieldSchema = block.fields.find( + (blockField) => 'name' in blockField && blockField.name === 'blockName', + ) } } @@ -428,16 +454,21 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom }), ) - const collapsedRowIDs = preferences?.fields?.[path]?.collapsed - acc.rowMetadata.push({ id: row.id, blockType: row.blockType, - collapsed: - collapsedRowIDs === undefined - ? field.admin.initCollapsed - : collapsedRowIDs.includes(row.id), }) + + const collapsedRowIDs = preferences?.fields?.[path]?.collapsed + + const collapsed = + collapsedRowIDs === undefined + ? field.admin.initCollapsed + : collapsedRowIDs.includes(row.id) + + if (collapsed) { + acc.rowMetadata[acc.rowMetadata.length - 1].collapsed = collapsed + } } return acc @@ -604,8 +635,10 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom } default: { - fieldState.value = data[field.name] - fieldState.initialValue = data[field.name] + if (data[field.name] !== undefined) { + fieldState.value = data[field.name] + fieldState.initialValue = data[field.name] + } // Add field to state if (!filter || filter(args)) { @@ -621,11 +654,10 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom if (!filter || filter(args)) { state[path] = { disableFormData: true, - errorPaths: [], - initialValue: undefined, - passesCondition, - valid: true, - value: undefined, + } + + if (passesCondition === false) { + state[path].passesCondition = false } } diff --git a/packages/ui/src/forms/fieldSchemasToFormState/renderField.tsx b/packages/ui/src/forms/fieldSchemasToFormState/renderField.tsx index deca48d7d..673938c04 100644 --- a/packages/ui/src/forms/fieldSchemasToFormState/renderField.tsx +++ b/packages/ui/src/forms/fieldSchemasToFormState/renderField.tsx @@ -52,7 +52,6 @@ export const renderField: RenderFieldMethod = ({ } const clientProps: ClientComponentProps & Partial = { - customComponents: fieldState?.customComponents || {}, field: clientField, path, permissions, @@ -60,6 +59,10 @@ export const renderField: RenderFieldMethod = ({ schemaPath, } + if (fieldState?.customComponents) { + clientProps.customComponents = fieldState.customComponents + } + // fields with subfields if (['array', 'blocks', 'collapsible', 'group', 'row', 'tabs'].includes(fieldConfig.type)) { clientProps.indexPath = indexPath @@ -88,10 +91,21 @@ export const renderField: RenderFieldMethod = ({ user: req.user, } - if (!fieldState?.customComponents) { - fieldState.customComponents = {} + /** + * Only create the `customComponents` object if needed. + * This will prevent unnecessary data from being transferred to the client. + */ + if (fieldConfig.admin) { + if ( + (Object.keys(fieldConfig.admin.components || {}).length > 0 || + fieldConfig.type === 'richText' || + ('description' in fieldConfig.admin && + typeof fieldConfig.admin.description === 'function')) && + !fieldState?.customComponents + ) { + fieldState.customComponents = {} + } } - switch (fieldConfig.type) { // TODO: handle block row labels as well in a similar fashion case 'array': { @@ -157,7 +171,9 @@ export const renderField: RenderFieldMethod = ({ if (key in defaultUIFieldComponentKeys) { continue } + const Component = fieldConfig.admin.components[key] + fieldState.customComponents[key] = RenderServerComponent({ clientProps, Component, @@ -176,17 +192,18 @@ export const renderField: RenderFieldMethod = ({ } if (fieldConfig.admin) { - if ('description' in fieldConfig.admin) { - if (typeof fieldConfig.admin?.description === 'function') { - fieldState.customComponents.Description = ( - - ) - } + if ( + 'description' in fieldConfig.admin && + typeof fieldConfig.admin?.description === 'function' + ) { + fieldState.customComponents.Description = ( + + ) } if (fieldConfig.admin?.components) { diff --git a/test/buildConfigWithDefaults.ts b/test/buildConfigWithDefaults.ts index 0de7b5ef9..48886a780 100644 --- a/test/buildConfigWithDefaults.ts +++ b/test/buildConfigWithDefaults.ts @@ -156,6 +156,11 @@ export async function buildConfigWithDefaults( config.admin = {} } + config.admin.experimental = { + ...(config.admin.experimental || {}), + optimizeFormState: true, + } + if (config.admin.autoLogin === undefined) { config.admin.autoLogin = process.env.PAYLOAD_PUBLIC_DISABLE_AUTO_LOGIN === 'true' || options?.disableAutoLogin diff --git a/tsconfig.base.json b/tsconfig.base.json index 9743db764..23c517dc7 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -28,7 +28,7 @@ } ], "paths": { - "@payload-config": ["./test/localization/config.ts"], + "@payload-config": ["./test/field-perf/config.ts"], "@payloadcms/live-preview": ["./packages/live-preview/src"], "@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"], "@payloadcms/live-preview-vue": ["./packages/live-preview-vue/src/index.ts"],