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:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ export const Account: React.FC<AdminViewProps> = async ({
|
||||
renderAllFields: true,
|
||||
req,
|
||||
schemaPath: collectionConfig.slug,
|
||||
skipValidation: true,
|
||||
})
|
||||
|
||||
// Fetch document lock state
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -63,6 +63,7 @@ export const CreateFirstUserView: React.FC<AdminViewProps> = async ({ initPageRe
|
||||
renderAllFields: true,
|
||||
req,
|
||||
schemaPath: collectionConfig.slug,
|
||||
skipValidation: true,
|
||||
})
|
||||
|
||||
return (
|
||||
|
||||
@@ -157,6 +157,7 @@ export const renderDocument = async ({
|
||||
renderAllFields: true,
|
||||
req,
|
||||
schemaPath: collectionSlug || globalSlug,
|
||||
skipValidation: true,
|
||||
}),
|
||||
])
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ export type BuildFormStateArgs = {
|
||||
req: PayloadRequest
|
||||
returnLockStatus?: boolean
|
||||
schemaPath: string
|
||||
skipValidation?: boolean
|
||||
updateLastEdited?: boolean
|
||||
} & (
|
||||
| {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -216,6 +216,7 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
|
||||
operation: 'create',
|
||||
renderAllFields: true,
|
||||
schemaPath: collectionSlug,
|
||||
skipValidation: true,
|
||||
})
|
||||
initialStateRef.current = formStateWithoutFiles
|
||||
setHasInitializedState(true)
|
||||
|
||||
@@ -185,6 +185,7 @@ export const EditManyDrawerContent: React.FC<
|
||||
operation: 'update',
|
||||
schemaPath: slug,
|
||||
signal: controller.signal,
|
||||
skipValidation: true,
|
||||
})
|
||||
|
||||
setInitialState(result)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -47,18 +47,11 @@ 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) {
|
||||
console.warn(
|
||||
'clientFieldSchemaMap is not passed to fieldSchemasToFormState - this will reduce performance',
|
||||
)
|
||||
}
|
||||
|
||||
const {
|
||||
export const fieldSchemasToFormState = async ({
|
||||
id,
|
||||
clientFieldSchemaMap,
|
||||
collectionSlug,
|
||||
@@ -73,7 +66,13 @@ export const fieldSchemasToFormState = async (args: Args): Promise<FormState> =>
|
||||
renderFieldFn,
|
||||
req,
|
||||
schemaPath,
|
||||
} = args
|
||||
skipValidation,
|
||||
}: Args): Promise<FormState> => {
|
||||
if (!clientFieldSchemaMap && renderFieldFn) {
|
||||
console.warn(
|
||||
'clientFieldSchemaMap is not passed to fieldSchemasToFormState - this will reduce performance',
|
||||
)
|
||||
}
|
||||
|
||||
if (fields && fields.length) {
|
||||
const state: FormStateWithoutComponents = {}
|
||||
@@ -110,6 +109,7 @@ export const fieldSchemasToFormState = async (args: Args): Promise<FormState> =>
|
||||
renderAllFields,
|
||||
renderFieldFn,
|
||||
req,
|
||||
skipValidation,
|
||||
state,
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user