Skip to content

Commit bd22444

Browse files
authored
perf(ui): skip rendering custom components hidden by admin.condition (#16780)
Form state was eagerly rendering custom components even when the field's `admin.condition` evaluates to false. The rendered React component was only being _hidden_ client-side, but should have deferred rendering entirely. This has potentially serious performance implications for two reasons: 1. Rendering overhead. Despite not mounting to the DOM, custom components still render prematurely, executing potentially expensive tasks like querying the database, etc. 1. Hidden fields still receive full recursion. Nested fields schemas are still traversed, build state, and resolve their filter options, etc. This includes the rendering step mentioned above. Now, custom server components DO NOT render unless they pass conditions, as expected. Custom components will now only render if they will be mounted to the page. Before: https://github.com/user-attachments/assets/6a010f6b-3f17-4e0e-b446-429c8c7306a9 After: https://github.com/user-attachments/assets/79b4f5c4-2ceb-47ff-a9dd-cacdb0ca110c
1 parent 6f782a1 commit bd22444

8 files changed

Lines changed: 341 additions & 55 deletions

File tree

packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts

Lines changed: 41 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,22 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
184184
fieldState.fieldSchema = field
185185
}
186186

187+
// Short-circuit to prevent hidden fields from recursing and rendering.
188+
// Note: `tab` is excluded bc tab visibility is keyed by `field.id` rather than `path`.
189+
// The tab branch below owns that write and the skip-recursion.
190+
if (passesCondition === false && field.type !== 'tab') {
191+
if (fieldAffectsData(field) && data?.[field.name] !== undefined) {
192+
fieldState.value = data[field.name]
193+
fieldState.initialValue = data[field.name]
194+
}
195+
196+
if (!filter || filter(args)) {
197+
state[path] = fieldState
198+
}
199+
200+
return
201+
}
202+
187203
if (fieldAffectsData(field) && !fieldIsHiddenOrDisabled(field) && field.type !== 'tab') {
188204
fieldPermissions =
189205
parentPermissions === true
@@ -808,10 +824,6 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
808824
state[path] = {
809825
disableFormData: true,
810826
}
811-
812-
if (passesCondition === false) {
813-
state[path].passesCondition = false
814-
}
815827
}
816828

817829
await iterateFields({
@@ -851,18 +863,10 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
851863
})
852864
} else if (field.type === 'tab') {
853865
const isNamedTab = tabHasName(field)
854-
let tabSelect: SelectType | undefined
855-
856-
const tabField: TabAsField = {
857-
...field,
858-
type: 'tab',
859-
}
860-
861-
let childPermissions: SanitizedFieldsPermissions = undefined
862866

863867
if (isNamedTab) {
864868
const shouldContinue = stripUnselectedFields({
865-
field: tabField,
869+
field: { ...field, type: 'tab' },
866870
select,
867871
selectMode,
868872
siblingDoc: data?.[field.name] || {},
@@ -871,16 +875,34 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
871875
if (!shouldContinue) {
872876
return
873877
}
878+
}
879+
880+
// Tab visibility on the client is keyed by `field.id`, not `path` (like all other fields).
881+
if (field?.id) {
882+
state[field.id] = {
883+
passesCondition,
884+
}
885+
886+
// Flag newly added tab entries so the client accepts them during merge.
887+
// Otherwise, tabs revealed after a hidden ancestor becomes visible would never make it into client form state.
888+
if (!renderAllFields && !previousFormState?.[field.id]) {
889+
state[field.id].addedByServer = true
890+
}
891+
}
874892

893+
if (!passesCondition) {
894+
return
895+
}
896+
897+
let childPermissions: SanitizedFieldsPermissions
898+
let tabSelect: SelectType | undefined
899+
900+
if (isNamedTab) {
875901
if (parentPermissions === true) {
876902
childPermissions = true
877903
} else {
878904
const tabPermissions = parentPermissions?.[field.name]
879-
if (tabPermissions === true) {
880-
childPermissions = true
881-
} else {
882-
childPermissions = tabPermissions?.fields
883-
}
905+
childPermissions = tabPermissions === true ? true : tabPermissions?.fields
884906
}
885907

886908
if (typeof select?.[field.name] === 'object') {
@@ -891,27 +913,6 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
891913
tabSelect = select
892914
}
893915

894-
const pathSegments = path ? path.split('.') : []
895-
896-
// If passesCondition is false then this should always result to false
897-
// If the tab has no admin.condition provided then fallback to passesCondition and let that decide the result
898-
let tabPassesCondition = passesCondition
899-
900-
if (passesCondition && typeof field.admin?.condition === 'function') {
901-
tabPassesCondition = field.admin.condition(fullData, data, {
902-
blockData,
903-
operation,
904-
path: pathSegments,
905-
user: req.user,
906-
})
907-
}
908-
909-
if (field?.id) {
910-
state[field.id] = {
911-
passesCondition: tabPassesCondition,
912-
}
913-
}
914-
915916
return iterateFields({
916917
id,
917918
addErrorPathToParent: addErrorPathToParentArg,
@@ -930,7 +931,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
930931
omitParents,
931932
operation,
932933
parentIndexPath: indexPath,
933-
parentPassesCondition: tabPassesCondition,
934+
parentPassesCondition: passesCondition,
934935
parentPath: path,
935936
parentSchemaPath: schemaPath,
936937
permissions: childPermissions,
@@ -951,10 +952,6 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
951952
state[path] = {
952953
disableFormData: true,
953954
}
954-
955-
if (passesCondition === false) {
956-
state[path].passesCondition = false
957-
}
958955
}
959956

960957
return iterateFields({

test/fields/payload-types.ts

Lines changed: 103 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export type SupportedTimezones =
6666
export interface Config {
6767
auth: {
6868
users: UserAuthOperations;
69+
'payload-mcp-api-keys': PayloadMcpApiKeyAuthOperations;
6970
};
7071
blocks: {
7172
ConfigBlockTest: ConfigBlockTest;
@@ -109,6 +110,7 @@ export interface Config {
109110
'uploads-multi-poly': UploadsMultiPoly;
110111
'uploads-restricted': UploadsRestricted;
111112
'ui-fields': UiField;
113+
'payload-mcp-api-keys': PayloadMcpApiKey;
112114
'payload-kv': PayloadKv;
113115
'payload-locked-documents': PayloadLockedDocument;
114116
'payload-preferences': PayloadPreference;
@@ -152,6 +154,7 @@ export interface Config {
152154
'uploads-multi-poly': UploadsMultiPolySelect<false> | UploadsMultiPolySelect<true>;
153155
'uploads-restricted': UploadsRestrictedSelect<false> | UploadsRestrictedSelect<true>;
154156
'ui-fields': UiFieldsSelect<false> | UiFieldsSelect<true>;
157+
'payload-mcp-api-keys': PayloadMcpApiKeysSelect<false> | PayloadMcpApiKeysSelect<true>;
155158
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
156159
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
157160
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
@@ -167,7 +170,7 @@ export interface Config {
167170
widgets: {
168171
collections: CollectionsWidget;
169172
};
170-
user: User;
173+
user: User | PayloadMcpApiKey;
171174
jobs: {
172175
tasks: unknown;
173176
workflows: unknown;
@@ -191,6 +194,24 @@ export interface UserAuthOperations {
191194
password: string;
192195
};
193196
}
197+
export interface PayloadMcpApiKeyAuthOperations {
198+
forgotPassword: {
199+
email: string;
200+
password: string;
201+
};
202+
login: {
203+
email: string;
204+
password: string;
205+
};
206+
registerFirstUser: {
207+
email: string;
208+
password: string;
209+
};
210+
unlock: {
211+
email: string;
212+
password: string;
213+
};
214+
}
194215
/**
195216
* This interface was referenced by `Config`'s JSON-Schema
196217
* via the `definition` "ConfigBlockTest".
@@ -1931,6 +1952,49 @@ export interface UiField {
19311952
updatedAt: string;
19321953
createdAt: string;
19331954
}
1955+
/**
1956+
* API keys control which collections, resources, tools, and prompts MCP clients can access
1957+
*
1958+
* This interface was referenced by `Config`'s JSON-Schema
1959+
* via the `definition` "payload-mcp-api-keys".
1960+
*/
1961+
export interface PayloadMcpApiKey {
1962+
id: string;
1963+
/**
1964+
* The user that the API key is associated with.
1965+
*/
1966+
user: string | User;
1967+
/**
1968+
* A useful label for the API key.
1969+
*/
1970+
label?: string | null;
1971+
/**
1972+
* The purpose of the API key.
1973+
*/
1974+
description?: string | null;
1975+
/**
1976+
* When checked, this key bypasses Payload access control on every operation it performs. Leave unchecked unless you have a specific reason.
1977+
*/
1978+
overrideAccess?: boolean | null;
1979+
/**
1980+
* Access for this API key — uncheck to revoke individual tools.
1981+
*/
1982+
access?:
1983+
| {
1984+
[k: string]: unknown;
1985+
}
1986+
| unknown[]
1987+
| string
1988+
| number
1989+
| boolean
1990+
| null;
1991+
updatedAt: string;
1992+
createdAt: string;
1993+
enableAPIKey?: boolean | null;
1994+
apiKey?: string | null;
1995+
apiKeyIndex?: string | null;
1996+
collection: 'payload-mcp-api-keys';
1997+
}
19341998
/**
19351999
* This interface was referenced by `Config`'s JSON-Schema
19362000
* via the `definition` "payload-kv".
@@ -2098,12 +2162,21 @@ export interface PayloadLockedDocument {
20982162
| ({
20992163
relationTo: 'ui-fields';
21002164
value: string | UiField;
2165+
} | null)
2166+
| ({
2167+
relationTo: 'payload-mcp-api-keys';
2168+
value: string | PayloadMcpApiKey;
21012169
} | null);
21022170
globalSlug?: string | null;
2103-
user: {
2104-
relationTo: 'users';
2105-
value: string | User;
2106-
};
2171+
user:
2172+
| {
2173+
relationTo: 'users';
2174+
value: string | User;
2175+
}
2176+
| {
2177+
relationTo: 'payload-mcp-api-keys';
2178+
value: string | PayloadMcpApiKey;
2179+
};
21072180
updatedAt: string;
21082181
createdAt: string;
21092182
}
@@ -2113,10 +2186,15 @@ export interface PayloadLockedDocument {
21132186
*/
21142187
export interface PayloadPreference {
21152188
id: string;
2116-
user: {
2117-
relationTo: 'users';
2118-
value: string | User;
2119-
};
2189+
user:
2190+
| {
2191+
relationTo: 'users';
2192+
value: string | User;
2193+
}
2194+
| {
2195+
relationTo: 'payload-mcp-api-keys';
2196+
value: string | PayloadMcpApiKey;
2197+
};
21202198
key?: string | null;
21212199
value?:
21222200
| {
@@ -3724,6 +3802,22 @@ export interface UiFieldsSelect<T extends boolean = true> {
37243802
updatedAt?: T;
37253803
createdAt?: T;
37263804
}
3805+
/**
3806+
* This interface was referenced by `Config`'s JSON-Schema
3807+
* via the `definition` "payload-mcp-api-keys_select".
3808+
*/
3809+
export interface PayloadMcpApiKeysSelect<T extends boolean = true> {
3810+
user?: T;
3811+
label?: T;
3812+
description?: T;
3813+
overrideAccess?: T;
3814+
access?: T;
3815+
updatedAt?: T;
3816+
createdAt?: T;
3817+
enableAPIKey?: T;
3818+
apiKey?: T;
3819+
apiKeyIndex?: T;
3820+
}
37273821
/**
37283822
* This interface was referenced by `Config`'s JSON-Schema
37293823
* via the `definition` "payload-kv_select".
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type { TextFieldServerComponent } from 'payload'
2+
3+
import { TextField } from '@payloadcms/ui'
4+
5+
export const CustomTextField: TextFieldServerComponent = ({
6+
clientField,
7+
path,
8+
payload,
9+
schemaPath,
10+
}) => {
11+
payload.logger.info('RENDERED CUSTOM SERVER COMPONENT')
12+
return <TextField field={clientField} path={path} schemaPath={schemaPath} />
13+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { CollectionConfig } from 'payload'
2+
3+
export const conditionsSlug = 'conditions'
4+
5+
export const ConditionsCollection: CollectionConfig = {
6+
slug: conditionsSlug,
7+
fields: [
8+
{
9+
name: 'showField',
10+
type: 'checkbox',
11+
},
12+
{
13+
name: 'conditionalCustomField',
14+
type: 'text',
15+
admin: {
16+
condition: (data) => data?.showField === true,
17+
components: {
18+
Field: './collections/Conditions/CustomField.js#CustomTextField',
19+
},
20+
},
21+
},
22+
],
23+
}

test/form-state/config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@ import path from 'path'
44
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
55
import { devUser } from '../credentials.js'
66
import { AutosavePostsCollection } from './collections/Autosave/index.js'
7+
import { ConditionsCollection } from './collections/Conditions/index.js'
78
import { PostsCollection, postsSlug } from './collections/Posts/index.js'
89

910
const filename = fileURLToPath(import.meta.url)
1011
const dirname = path.dirname(filename)
1112

1213
export default buildConfigWithDefaults({
13-
collections: [PostsCollection, AutosavePostsCollection],
14+
collections: [PostsCollection, AutosavePostsCollection, ConditionsCollection],
1415
admin: {
1516
importMap: {
1617
baseDir: path.resolve(dirname),

0 commit comments

Comments
 (0)