perf: skips field validations until the form is submitted (#10580)

Field validations can be expensive, especially custom validations that
are async or highly complex. This can lead to slow form state response
times when generating form state for many such fields. Ideally, we only
run validations on fields whose values have changed. This is not
possible, however, because field validation functions might reference
_other_ field values with their args, and there is no good way of
detecting exactly which fields should run in this case. The next best
thing here is to only run validations _after the form has been
submitted_, and then every `onChange` event thereafter until a
successful submit has taken place. This is an elegant solution because
we currently don't _render_ field errors until submission anyway.

This change will significantly speed up form state response times, at
least until the form has been submitted. From then on, all field
validations will run regardless, just as they do now. If custom
validations continue to slow down form state response times, there is a
new `event` arg introduced in #10738 that can be used to control whether
heavy operations occur on change or on submit.

Related: #10638
This commit is contained in:
Jacob Fletcher
2025-01-27 15:21:33 -05:00
committed by GitHub
parent 0acaf8a7f7
commit 82f1bb9864
15 changed files with 48 additions and 29 deletions

View File

@@ -3,7 +3,10 @@ import { TenantSelector as TenantSelector_d6d5f193a167989e2ee7d14202901e62 } fro
import { TenantSelectionProvider as TenantSelectionProvider_1d0591e3cf4f332c83a86da13a0de59a } from '@payloadcms/plugin-multi-tenant/client'
export const importMap = {
"@payloadcms/plugin-multi-tenant/client#TenantField": TenantField_1d0591e3cf4f332c83a86da13a0de59a,
"@payloadcms/plugin-multi-tenant/rsc#TenantSelector": TenantSelector_d6d5f193a167989e2ee7d14202901e62,
"@payloadcms/plugin-multi-tenant/client#TenantSelectionProvider": TenantSelectionProvider_1d0591e3cf4f332c83a86da13a0de59a
'@payloadcms/plugin-multi-tenant/client#TenantField':
TenantField_1d0591e3cf4f332c83a86da13a0de59a,
'@payloadcms/plugin-multi-tenant/rsc#TenantSelector':
TenantSelector_d6d5f193a167989e2ee7d14202901e62,
'@payloadcms/plugin-multi-tenant/client#TenantSelectionProvider':
TenantSelectionProvider_1d0591e3cf4f332c83a86da13a0de59a,
}

View File

@@ -88,6 +88,7 @@ export const Account: React.FC<AdminViewProps> = async ({
renderAllFields: true,
req,
schemaPath: collectionConfig.slug,
skipValidation: true,
})
// Fetch document lock state

View File

@@ -47,7 +47,7 @@ export const CreateFirstUserClient: React.FC<{
const collectionConfig = getEntityConfig({ collectionSlug: userSlug })
const onChange: FormProps['onChange'][0] = React.useCallback(
async ({ formState: prevFormState }) => {
async ({ formState: prevFormState, submitted }) => {
const controller = handleAbortRef(abortOnChangeRef)
const response = await getFormState({
@@ -58,6 +58,7 @@ export const CreateFirstUserClient: React.FC<{
operation: 'create',
schemaPath: userSlug,
signal: controller.signal,
skipValidation: !submitted,
})
abortOnChangeRef.current = null

View File

@@ -63,6 +63,7 @@ export const CreateFirstUserView: React.FC<AdminViewProps> = async ({ initPageRe
renderAllFields: true,
req,
schemaPath: collectionConfig.slug,
skipValidation: true,
})
return (

View File

@@ -157,6 +157,7 @@ export const renderDocument = async ({
renderAllFields: true,
req,
schemaPath: collectionSlug || globalSlug,
skipValidation: true,
}),
])

View File

@@ -225,6 +225,7 @@ const PreviewView: React.FC<Props> = ({
returnLockStatus: false,
schemaPath: entitySlug,
signal: controller.signal,
skipValidation: true,
})
// Unlock the document after save
@@ -267,7 +268,7 @@ const PreviewView: React.FC<Props> = ({
)
const onChange: FormProps['onChange'][0] = useCallback(
async ({ formState: prevFormState }) => {
async ({ formState: prevFormState, submitted }) => {
const controller = handleAbortRef(abortOnChangeRef)
const currentTime = Date.now()
@@ -292,6 +293,7 @@ const PreviewView: React.FC<Props> = ({
returnLockStatus: isLockingEnabled ? true : false,
schemaPath,
signal: controller.signal,
skipValidation: !submitted,
updateLastEdited,
})

View File

@@ -84,6 +84,7 @@ export type BuildFormStateArgs = {
req: PayloadRequest
returnLockStatus?: boolean
schemaPath: string
skipValidation?: boolean
updateLastEdited?: boolean
} & (
| {

View File

@@ -108,7 +108,7 @@ export function EditForm({ submitted }: EditFormProps) {
)
const onChange: NonNullable<FormProps['onChange']>[0] = useCallback(
async ({ formState: prevFormState }) => {
async ({ formState: prevFormState, submitted }) => {
const controller = handleAbortRef(abortOnChangeRef)
const docPreferences = await getDocPreferences()
@@ -121,6 +121,7 @@ export function EditForm({ submitted }: EditFormProps) {
operation: 'create',
schemaPath,
signal: controller.signal,
skipValidation: !submitted,
})
abortOnChangeRef.current = null

View File

@@ -216,6 +216,7 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
operation: 'create',
renderAllFields: true,
schemaPath: collectionSlug,
skipValidation: true,
})
initialStateRef.current = formStateWithoutFiles
setHasInitializedState(true)

View File

@@ -185,6 +185,7 @@ export const EditManyDrawerContent: React.FC<
operation: 'update',
schemaPath: slug,
signal: controller.signal,
skipValidation: true,
})
setInitialState(result)

View File

@@ -505,6 +505,7 @@ export const Form: React.FC<FormProps> = (props) => {
renderAllFields: true,
schemaPath: collectionSlug ? collectionSlug : globalSlug,
signal: controller.signal,
skipValidation: true,
})
contextRef.current = { ...initContextState } as FormContextType
@@ -665,6 +666,7 @@ export const Form: React.FC<FormProps> = (props) => {
// Edit view default onChange is in packages/ui/src/views/Edit/index.tsx. This onChange usually sends a form state request
revalidatedFormState = await onChangeFn({
formState: deepCopyObjectSimpleWithoutReactComponents(contextRef.current.fields),
submitted,
})
}
@@ -699,7 +701,7 @@ export const Form: React.FC<FormProps> = (props) => {
`fields` updates before `modified`, because setModified is in a setTimeout.
So on the first change, modified is false, so we don't trigger the effect even though we should.
**/
[contextRef.current.fields, modified],
[contextRef.current.fields, modified, submitted],
[dispatchFields, onChange],
{
delay: 250,

View File

@@ -39,7 +39,7 @@ export type FormProps = {
initialState?: FormState
isInitializing?: boolean
log?: boolean
onChange?: ((args: { formState: FormState }) => Promise<FormState>)[]
onChange?: ((args: { formState: FormState; submitted?: boolean }) => Promise<FormState>)[]
onSubmit?: (fields: FormState, data: Data) => void
onSuccess?: (json: unknown) => Promise<FormState | void> | void
redirect?: string

View File

@@ -47,34 +47,33 @@ type Args = {
renderAllFields: boolean
renderFieldFn?: RenderFieldMethod
req: PayloadRequest
schemaPath: string
skipValidation?: boolean
}
export const fieldSchemasToFormState = async (args: Args): Promise<FormState> => {
if (!args.clientFieldSchemaMap && args.renderFieldFn) {
export const fieldSchemasToFormState = async ({
id,
clientFieldSchemaMap,
collectionSlug,
data = {},
fields,
fieldSchemaMap,
operation,
permissions,
preferences,
previousFormState,
renderAllFields,
renderFieldFn,
req,
schemaPath,
skipValidation,
}: Args): Promise<FormState> => {
if (!clientFieldSchemaMap && renderFieldFn) {
console.warn(
'clientFieldSchemaMap is not passed to fieldSchemasToFormState - this will reduce performance',
)
}
const {
id,
clientFieldSchemaMap,
collectionSlug,
data = {},
fields,
fieldSchemaMap,
operation,
permissions,
preferences,
previousFormState,
renderAllFields,
renderFieldFn,
req,
schemaPath,
} = args
if (fields && fields.length) {
const state: FormStateWithoutComponents = {}
@@ -110,6 +109,7 @@ export const fieldSchemasToFormState = async (args: Args): Promise<FormState> =>
renderAllFields,
renderFieldFn,
req,
skipValidation,
state,
})

View File

@@ -114,6 +114,7 @@ export const buildFormState = async (
},
returnLockStatus,
schemaPath = collectionSlug || globalSlug,
skipValidation,
updateLastEdited,
} = args
@@ -194,6 +195,7 @@ export const buildFormState = async (
renderFieldFn: renderField,
req,
schemaPath,
skipValidation,
})
// Maintain form state of auth / upload fields

View File

@@ -280,6 +280,7 @@ export const DefaultEditView: React.FC<ClientSideEditViewProps> = ({
returnLockStatus: false,
schemaPath: schemaPathSegments.join('.'),
signal: controller.signal,
skipValidation: true,
})
// Unlock the document after save
@@ -323,7 +324,7 @@ export const DefaultEditView: React.FC<ClientSideEditViewProps> = ({
)
const onChange: FormProps['onChange'][0] = useCallback(
async ({ formState: prevFormState }) => {
async ({ formState: prevFormState, submitted }) => {
const controller = handleAbortRef(abortOnChangeRef)
const currentTime = Date.now()
@@ -345,6 +346,7 @@ export const DefaultEditView: React.FC<ClientSideEditViewProps> = ({
formState: prevFormState,
globalSlug,
operation,
skipValidation: !submitted,
// Performance optimization: Setting it to false ensure that only fields that have explicit requireRender set in the form state will be rendered (e.g. new array rows).
// We only want to render ALL fields on initial render, not in onChange.
renderAllFields: false,