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 |
This commit is contained in:
Jacob Fletcher
2025-01-14 10:45:54 -05:00
committed by GitHub
parent 8217842bb3
commit 31ae27b67d
11 changed files with 160 additions and 98 deletions

View File

@@ -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

View File

@@ -18,7 +18,7 @@ import type {
export type ClientFieldWithOptionalType = MarkOptional<ClientField, 'type'>
export type ClientComponentProps = {
customComponents: FormField['customComponents']
customComponents?: FormField['customComponents']
field: ClientBlock | ClientField | ClientTab
forceRender?: boolean
permissions?: SanitizedFieldPermissions

View File

@@ -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<FieldState, 'customComponents'>

View File

@@ -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

View File

@@ -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()

View File

@@ -1,4 +1,4 @@
import type { FormState } from 'payload'
import type { FormFieldWithoutComponents, FormState } from 'payload'
export type State = {
activeIndex: number

View File

@@ -228,6 +228,7 @@ export const Form: React.FC<FormProps> = (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<FormProps> = (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<FormProps> = (props) => {
const serializableFields = deepCopyObjectSimpleWithoutReactComponents(
contextRef.current.fields,
)
const data = reduceFieldsToValues(serializableFields, true)
for (const [key, value] of Object.entries(overrides)) {

View File

@@ -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
}
}

View File

@@ -52,7 +52,6 @@ export const renderField: RenderFieldMethod = ({
}
const clientProps: ClientComponentProps & Partial<FieldPaths> = {
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 = (
<FieldDescription
description={fieldConfig.admin?.description({
t: req.i18n.t,
})}
path={path}
/>
)
}
if (
'description' in fieldConfig.admin &&
typeof fieldConfig.admin?.description === 'function'
) {
fieldState.customComponents.Description = (
<FieldDescription
description={fieldConfig.admin?.description({
t: req.i18n.t,
})}
path={path}
/>
)
}
if (fieldConfig.admin?.components) {

View File

@@ -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

View File

@@ -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"],