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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FormState } from 'payload'
|
||||
import type { FormFieldWithoutComponents, FormState } from 'payload'
|
||||
|
||||
export type State = {
|
||||
activeIndex: number
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user