Skip to content

Commit 0be11b6

Browse files
authored
perf(ui): skip rendering custom components hidden by admin.condition (#16819)
Identical to #16780, back-ported for 3.x.
1 parent 79b4e4c commit 0be11b6

7 files changed

Lines changed: 340 additions & 54 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".
@@ -1911,6 +1932,49 @@ export interface UiField {
19111932
updatedAt: string;
19121933
createdAt: string;
19131934
}
1935+
/**
1936+
* API keys control which collections, resources, tools, and prompts MCP clients can access
1937+
*
1938+
* This interface was referenced by `Config`'s JSON-Schema
1939+
* via the `definition` "payload-mcp-api-keys".
1940+
*/
1941+
export interface PayloadMcpApiKey {
1942+
id: string;
1943+
/**
1944+
* The user that the API key is associated with.
1945+
*/
1946+
user: string | User;
1947+
/**
1948+
* A useful label for the API key.
1949+
*/
1950+
label?: string | null;
1951+
/**
1952+
* The purpose of the API key.
1953+
*/
1954+
description?: string | null;
1955+
/**
1956+
* When checked, this key bypasses Payload access control on every operation it performs. Leave unchecked unless you have a specific reason.
1957+
*/
1958+
overrideAccess?: boolean | null;
1959+
/**
1960+
* Access for this API key — uncheck to revoke individual tools.
1961+
*/
1962+
access?:
1963+
| {
1964+
[k: string]: unknown;
1965+
}
1966+
| unknown[]
1967+
| string
1968+
| number
1969+
| boolean
1970+
| null;
1971+
updatedAt: string;
1972+
createdAt: string;
1973+
enableAPIKey?: boolean | null;
1974+
apiKey?: string | null;
1975+
apiKeyIndex?: string | null;
1976+
collection: 'payload-mcp-api-keys';
1977+
}
19141978
/**
19151979
* This interface was referenced by `Config`'s JSON-Schema
19161980
* via the `definition` "payload-kv".
@@ -2078,12 +2142,21 @@ export interface PayloadLockedDocument {
20782142
| ({
20792143
relationTo: 'ui-fields';
20802144
value: string | UiField;
2145+
} | null)
2146+
| ({
2147+
relationTo: 'payload-mcp-api-keys';
2148+
value: string | PayloadMcpApiKey;
20812149
} | null);
20822150
globalSlug?: string | null;
2083-
user: {
2084-
relationTo: 'users';
2085-
value: string | User;
2086-
};
2151+
user:
2152+
| {
2153+
relationTo: 'users';
2154+
value: string | User;
2155+
}
2156+
| {
2157+
relationTo: 'payload-mcp-api-keys';
2158+
value: string | PayloadMcpApiKey;
2159+
};
20872160
updatedAt: string;
20882161
createdAt: string;
20892162
}
@@ -2093,10 +2166,15 @@ export interface PayloadLockedDocument {
20932166
*/
20942167
export interface PayloadPreference {
20952168
id: string;
2096-
user: {
2097-
relationTo: 'users';
2098-
value: string | User;
2099-
};
2169+
user:
2170+
| {
2171+
relationTo: 'users';
2172+
value: string | User;
2173+
}
2174+
| {
2175+
relationTo: 'payload-mcp-api-keys';
2176+
value: string | PayloadMcpApiKey;
2177+
};
21002178
key?: string | null;
21012179
value?:
21022180
| {
@@ -3704,6 +3782,22 @@ export interface UiFieldsSelect<T extends boolean = true> {
37043782
updatedAt?: T;
37053783
createdAt?: T;
37063784
}
3785+
/**
3786+
* This interface was referenced by `Config`'s JSON-Schema
3787+
* via the `definition` "payload-mcp-api-keys_select".
3788+
*/
3789+
export interface PayloadMcpApiKeysSelect<T extends boolean = true> {
3790+
user?: T;
3791+
label?: T;
3792+
description?: T;
3793+
overrideAccess?: T;
3794+
access?: T;
3795+
updatedAt?: T;
3796+
createdAt?: T;
3797+
enableAPIKey?: T;
3798+
apiKey?: T;
3799+
apiKeyIndex?: T;
3800+
}
37073801
/**
37083802
* This interface was referenced by `Config`'s JSON-Schema
37093803
* 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)