perf: only validate filter options on submit (#10738)

Field validations currently run very often, such as within form state on
type. This can lead to serious performance implications within the admin
panel if those validation functions are async, especially if they
perform expensive database queries. One glaring example of this is how
all relationship and upload fields perform a database lookup in order to
evaluate that the given value(s) satisfy the defined filter options. If
the field is polymorphic, this can happen multiple times over, one for
each collection. Similarly, custom validation functions might also
perform expensive tasks, something that Payload has no control over.

The fix here is two-fold. First, we now provide a new `event` arg to all
`validate` functions that allow you to opt-in to performing expensive
operations _only when documents are submitted_, and fallback to
significantly more performant validations as form state is generated.
This new pattern will be the new default for relationship and upload
fields, however, any custom validation functions will need to be
implemented in this way in order to take advantage of it. Here's what
that might look like:

```
[
  // ...
  {
    name: 'text'
    type: 'text',
    validate: async (value, { event }) => {
      if (event === 'onChange') {
        // Do something highly performant here
        return true
      }
      
      // Do something more expensive here
      return true
    }
  }
]
```

The second part of this is to only run validations _after the form as
been submitted_, and then every change event thereafter. This work is
being done in #10580.
This commit is contained in:
Jacob Fletcher
2025-01-23 15:10:31 -05:00
committed by GitHub
parent 9f2bca104b
commit a05240a853
15 changed files with 99 additions and 12 deletions

View File

@@ -290,12 +290,13 @@ export const MyField: Field = {
The following additional properties are provided in the `ctx` object:
| Property | Description |
| ------------- | ------------------------------------------------------------------------------------------------------------------------ |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `data` | An object containing the full collection or global document currently being edited. |
| `siblingData` | An object containing document data that is scoped to only fields within the same parent of this field. |
| `operation` | Will be `create` or `update` depending on the UI action or API call. |
| `id` | The `id` of the current document being edited. `id` is `undefined` during the `create` operation. |
| `req` | The current HTTP request object. Contains `payload`, `user`, etc. |
| `event` | Either `onChange` or `submit` depending on the current action. Used as a performance opt-in. [More details](#async-field-validations). |
#### Reusing Default Field Validations
@@ -316,10 +317,37 @@ const field: Field = {
}
```
Here is a list of all default field validation functions:
```ts
import {
array,
blocks,
checkbox,
code,
date,
email,
group,
json,
number,
point,
radio,
relationship,
richText,
select,
tabs,
text,
textarea,
upload,
} from 'payload/shared'
```
#### Async Field Validations
Custom validation functions can also be asynchronous depending on your needs. This makes it possible to make requests to external services or perform other miscellaneous asynchronous logic.
When writing async validation functions, it is important to consider the performance implications. Validations are executed on every change to the field, so they should be as lightweight as possible. If you need to perform expensive validations, such as querying the database, consider using the `event` property in the `ctx` object to only run the validation on form submission.
To write asynchronous validation functions, use the `async` keyword to define your function:
```ts
@@ -332,10 +360,18 @@ export const Orders: CollectionConfig = {
name: 'customerNumber',
type: 'text',
// highlight-start
validate: async (val, { operation }) => {
if (operation !== 'create') return true
validate: async (val, { event }) => {
if (event === 'onChange') {
return true
}
// only perform expensive validation when the form is submitted
const response = await fetch(`https://your-api.com/customers/${val}`)
if (response.ok) return true
if (response.ok) {
return true
}
return 'The customer number provided does not match any customers within our records.'
},
// highlight-end

View File

@@ -92,6 +92,7 @@ export const ForgotPasswordForm: React.FC = () => {
name: 'username',
type: 'text',
data: {},
event: 'onChange',
preferences: { fields: {} },
req: {
payload: {
@@ -120,6 +121,7 @@ export const ForgotPasswordForm: React.FC = () => {
name: 'email',
type: 'email',
data: {},
event: 'onChange',
preferences: { fields: {} },
req: { payload: { config }, t } as unknown as PayloadRequest,
required: true,

View File

@@ -35,6 +35,7 @@ export const generatePasswordSaltHash = async ({
name: 'password',
type: 'text',
data: {},
event: 'submit',
preferences: { fields: {} },
req,
required: true,

View File

@@ -344,6 +344,7 @@ export type LabelsClient = {
export type BaseValidateOptions<TData, TSiblingData, TValue> = {
collectionSlug?: string
data: Partial<TData>
event?: 'onChange' | 'submit'
id?: number | string
operation?: Operation
preferences: DocumentPreferences

View File

@@ -4,7 +4,7 @@ import type { ValidationFieldError } from '../../../errors/index.js'
import type { SanitizedGlobalConfig } from '../../../globals/config/types.js'
import type { RequestContext } from '../../../index.js'
import type { JsonObject, Operation, PayloadRequest } from '../../../types/index.js'
import type { Field, TabAsField } from '../../config/types.js'
import type { BaseValidateOptions, Field, TabAsField } from '../../config/types.js'
import { MissingEditorProp } from '../../../errors/index.js'
import { deepMergeWithSourceArrays } from '../../../utilities/deepMerge.js'
@@ -151,6 +151,7 @@ export const promise = async ({
id,
collectionSlug: collection?.slug,
data: deepMergeWithSourceArrays(doc, data),
event: 'submit',
jsonError,
operation,
preferences: { fields: {} },

View File

@@ -644,6 +644,7 @@ export type UploadFieldSingleValidation = Validate<unknown, unknown, unknown, Up
export const upload: UploadFieldValidation = async (value, options) => {
const {
event,
maxRows,
minRows,
relationTo,
@@ -716,6 +717,10 @@ export const upload: UploadFieldValidation = async (value, options) => {
}
}
if (event === 'onChange') {
return true
}
return validateFilterOptions(value, options)
}
@@ -742,6 +747,7 @@ export type RelationshipFieldSingleValidation = Validate<
export const relationship: RelationshipFieldValidation = async (value, options) => {
const {
event,
maxRows,
minRows,
relationTo,
@@ -814,6 +820,10 @@ export const relationship: RelationshipFieldValidation = async (value, options)
}
}
if (event === 'onChange') {
return true
}
return validateFilterOptions(value, options)
}

View File

@@ -50,6 +50,7 @@ const PasswordFieldComponent: React.FC<PasswordFieldProps> = (props) => {
name: 'password',
type: 'text',
data: {},
event: 'onChange',
preferences: { fields: {} },
req: {
payload: {

View File

@@ -131,6 +131,7 @@ export const Form: React.FC<FormProps> = (props) => {
id,
collectionSlug,
data,
event: 'submit',
operation,
preferences: {} as any,
req: {

View File

@@ -190,6 +190,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
id,
collectionSlug,
data: fullData,
event: 'onChange',
jsonError,
operation,
preferences,

View File

@@ -148,6 +148,7 @@ export const useField = <TValue,>(options: Options): FieldType<TValue> => {
id,
collectionSlug,
data: getData(),
event: 'onChange',
operation,
preferences: {} as any,
req: {

View File

@@ -39,6 +39,7 @@ export const APIKey: React.FC<{ readonly enabled: boolean; readonly readOnly?: b
name: 'apiKey',
type: 'text',
data: {},
event: 'onChange',
maxLength: 48,
minLength: 24,
preferences: { fields: {} },

View File

@@ -235,6 +235,25 @@ export const Posts: CollectionConfig = {
position: 'sidebar',
},
},
{
name: 'validateUsingEvent',
type: 'text',
admin: {
description:
'This field should only validate on submit. Try typing "Not allowed" and submitting the form.',
},
validate: (value, { event }) => {
if (event === 'onChange') {
return true
}
if (value === 'Not allowed') {
return 'This field has been validated only on submit'
}
return true
},
},
],
labels: {
plural: slugPluralLabel,

View File

@@ -190,6 +190,13 @@ describe('Document View', () => {
await saveDocAndAssert(page)
await expect(page.locator('#field-title')).toBeEnabled()
})
test('should thread proper event argument to validation functions', async () => {
await page.goto(postsUrl.create)
await page.locator('#field-title').fill(title)
await page.locator('#field-validateUsingEvent').fill('Not allowed')
await saveDocAndAssert(page, '#action-save', 'error')
})
})
describe('document titles', () => {

View File

@@ -188,6 +188,10 @@ export interface Post {
* This is a very long description that takes many characters to complete and hopefully will wrap instead of push the sidebar open, lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum voluptates. Quisquam, voluptatum voluptates.
*/
sidebarField?: string | null;
/**
* This field should only validate on submit. Try typing "Not allowed" and submitting the form.
*/
validateUsingEvent?: string | null;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
@@ -583,6 +587,7 @@ export interface PostsSelect<T extends boolean = true> {
disableListColumnText?: T;
disableListFilterText?: T;
sidebarField?: T;
validateUsingEvent?: T;
updatedAt?: T;
createdAt?: T;
_status?: T;

View File

@@ -31,7 +31,7 @@
}
],
"paths": {
"@payload-config": ["./test/fields/config.ts"],
"@payload-config": ["./test/admin/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"],