From a05240a853c759eb069f7c00367226e16fbc87cd Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Thu, 23 Jan 2025 15:10:31 -0500 Subject: [PATCH] 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. --- docs/fields/overview.mdx | 56 +++++++++++++++---- .../ForgotPasswordForm/index.tsx | 2 + .../local/generatePasswordSaltHash.ts | 1 + packages/payload/src/fields/config/types.ts | 1 + .../src/fields/hooks/beforeChange/promise.ts | 3 +- packages/payload/src/fields/validations.ts | 10 ++++ packages/ui/src/fields/Password/index.tsx | 1 + packages/ui/src/forms/Form/index.tsx | 1 + .../addFieldStatePromise.ts | 1 + packages/ui/src/forms/useField/index.tsx | 1 + packages/ui/src/views/Edit/Auth/APIKey.tsx | 1 + test/admin/collections/Posts.ts | 19 +++++++ test/admin/e2e/document-view/e2e.spec.ts | 7 +++ test/admin/payload-types.ts | 5 ++ tsconfig.base.json | 2 +- 15 files changed, 99 insertions(+), 12 deletions(-) diff --git a/docs/fields/overview.mdx b/docs/fields/overview.mdx index 88b9e3235f..3565f5c086 100644 --- a/docs/fields/overview.mdx +++ b/docs/fields/overview.mdx @@ -289,13 +289,14 @@ 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. | +| 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 diff --git a/packages/next/src/views/ForgotPassword/ForgotPasswordForm/index.tsx b/packages/next/src/views/ForgotPassword/ForgotPasswordForm/index.tsx index e74b0cfcb9..50625e283e 100644 --- a/packages/next/src/views/ForgotPassword/ForgotPasswordForm/index.tsx +++ b/packages/next/src/views/ForgotPassword/ForgotPasswordForm/index.tsx @@ -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, diff --git a/packages/payload/src/auth/strategies/local/generatePasswordSaltHash.ts b/packages/payload/src/auth/strategies/local/generatePasswordSaltHash.ts index 2e2767b700..8106b0d429 100644 --- a/packages/payload/src/auth/strategies/local/generatePasswordSaltHash.ts +++ b/packages/payload/src/auth/strategies/local/generatePasswordSaltHash.ts @@ -35,6 +35,7 @@ export const generatePasswordSaltHash = async ({ name: 'password', type: 'text', data: {}, + event: 'submit', preferences: { fields: {} }, req, required: true, diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index 903ed00f51..ea5d973b89 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -344,6 +344,7 @@ export type LabelsClient = { export type BaseValidateOptions = { collectionSlug?: string data: Partial + event?: 'onChange' | 'submit' id?: number | string operation?: Operation preferences: DocumentPreferences diff --git a/packages/payload/src/fields/hooks/beforeChange/promise.ts b/packages/payload/src/fields/hooks/beforeChange/promise.ts index a2877da507..2f0641d64e 100644 --- a/packages/payload/src/fields/hooks/beforeChange/promise.ts +++ b/packages/payload/src/fields/hooks/beforeChange/promise.ts @@ -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: {} }, diff --git a/packages/payload/src/fields/validations.ts b/packages/payload/src/fields/validations.ts index c03d2aee66..94250728f9 100644 --- a/packages/payload/src/fields/validations.ts +++ b/packages/payload/src/fields/validations.ts @@ -644,6 +644,7 @@ export type UploadFieldSingleValidation = Validate { 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) } diff --git a/packages/ui/src/fields/Password/index.tsx b/packages/ui/src/fields/Password/index.tsx index c85181128c..f560374cb2 100644 --- a/packages/ui/src/fields/Password/index.tsx +++ b/packages/ui/src/fields/Password/index.tsx @@ -50,6 +50,7 @@ const PasswordFieldComponent: React.FC = (props) => { name: 'password', type: 'text', data: {}, + event: 'onChange', preferences: { fields: {} }, req: { payload: { diff --git a/packages/ui/src/forms/Form/index.tsx b/packages/ui/src/forms/Form/index.tsx index 3c1dabe63d..663b6e9106 100644 --- a/packages/ui/src/forms/Form/index.tsx +++ b/packages/ui/src/forms/Form/index.tsx @@ -131,6 +131,7 @@ export const Form: React.FC = (props) => { id, collectionSlug, data, + event: 'submit', operation, preferences: {} as any, req: { diff --git a/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts b/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts index 3c1ce0af13..ad8443725c 100644 --- a/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts +++ b/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts @@ -190,6 +190,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom id, collectionSlug, data: fullData, + event: 'onChange', jsonError, operation, preferences, diff --git a/packages/ui/src/forms/useField/index.tsx b/packages/ui/src/forms/useField/index.tsx index 9c12c33ab4..9b8223285f 100644 --- a/packages/ui/src/forms/useField/index.tsx +++ b/packages/ui/src/forms/useField/index.tsx @@ -148,6 +148,7 @@ export const useField = (options: Options): FieldType => { id, collectionSlug, data: getData(), + event: 'onChange', operation, preferences: {} as any, req: { diff --git a/packages/ui/src/views/Edit/Auth/APIKey.tsx b/packages/ui/src/views/Edit/Auth/APIKey.tsx index a1f2495609..f6dee201b4 100644 --- a/packages/ui/src/views/Edit/Auth/APIKey.tsx +++ b/packages/ui/src/views/Edit/Auth/APIKey.tsx @@ -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: {} }, diff --git a/test/admin/collections/Posts.ts b/test/admin/collections/Posts.ts index e8e00bf71f..3de7b281dc 100644 --- a/test/admin/collections/Posts.ts +++ b/test/admin/collections/Posts.ts @@ -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, diff --git a/test/admin/e2e/document-view/e2e.spec.ts b/test/admin/e2e/document-view/e2e.spec.ts index 6651cbff7e..405f086ca6 100644 --- a/test/admin/e2e/document-view/e2e.spec.ts +++ b/test/admin/e2e/document-view/e2e.spec.ts @@ -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', () => { diff --git a/test/admin/payload-types.ts b/test/admin/payload-types.ts index 448fcf5500..4b93cdf3eb 100644 --- a/test/admin/payload-types.ts +++ b/test/admin/payload-types.ts @@ -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 { disableListColumnText?: T; disableListFilterText?: T; sidebarField?: T; + validateUsingEvent?: T; updatedAt?: T; createdAt?: T; _status?: T; diff --git a/tsconfig.base.json b/tsconfig.base.json index fba7b319e9..c461af5dcb 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -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"],