Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
bbb3e7a
perf(ui): skip rendering custom field components for fields hidden by…
jacobsfletch May 28, 2026
2f1d626
chore: move conditional custom Field fixture into dedicated Condition…
jacobsfletch May 29, 2026
8558d04
chore: use checkbox trigger for conditional custom Field fixture
jacobsfletch May 29, 2026
4f7ac05
isolate test config
jacobsfletch May 29, 2026
821bae4
perf(ui): short-circuit form-state processing for fields failing admi…
jacobsfletch May 29, 2026
f1823a8
cleanup
jacobsfletch May 29, 2026
bdefbf2
chore(ui): preserve tabs visibility behavior when condition fails
jacobsfletch May 29, 2026
8c64970
chore: remove tabs fixtures from form-state Conditions
jacobsfletch May 29, 2026
abf494d
fix(ui): preserve value/initialValue when short-circuiting hidden fields
jacobsfletch May 29, 2026
2093f15
Merge branch 'main' into perf/server-component-rendering-conditions
jacobsfletch Jun 1, 2026
044efd7
perf(ui): extend form-state short-circuit to tabs/tab fields
jacobsfletch Jun 1, 2026
f6ad148
fix(ui): write state[field.id] when short-circuiting hidden tab fields
jacobsfletch Jun 1, 2026
b7dec6e
refactor(ui): centralize hidden-tab handling in tab branch
jacobsfletch Jun 1, 2026
db7fda3
refactor(ui): order tab branch top-to-bottom for clarity
jacobsfletch Jun 1, 2026
9cd2dc9
cleanup jsdocs
jacobsfletch Jun 1, 2026
4cc6ca7
fix(ui): flag tab state entries as addedByServer when newly created
jacobsfletch Jun 1, 2026
4308fa4
cleanup comments
jacobsfletch Jun 1, 2026
d374cf2
chore: restore test/_community/payload-types.ts to main
jacobsfletch Jun 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,22 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
fieldState.fieldSchema = field
}

// Short-circuit to prevent hidden fields from recursing and rendering.
// Note: `tab` is excluded bc tab visibility is keyed by `field.id` rather than `path`.
// The tab branch below owns that write and the skip-recursion.
if (passesCondition === false && field.type !== 'tab') {
if (fieldAffectsData(field) && data?.[field.name] !== undefined) {
fieldState.value = data[field.name]
fieldState.initialValue = data[field.name]
}

if (!filter || filter(args)) {
state[path] = fieldState
}

return
}

if (fieldAffectsData(field) && !fieldIsHiddenOrDisabled(field) && field.type !== 'tab') {
fieldPermissions =
parentPermissions === true
Expand Down Expand Up @@ -808,10 +824,6 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
state[path] = {
disableFormData: true,
}

if (passesCondition === false) {
state[path].passesCondition = false
}
}

await iterateFields({
Expand Down Expand Up @@ -851,18 +863,10 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
})
} else if (field.type === 'tab') {
const isNamedTab = tabHasName(field)
let tabSelect: SelectType | undefined

const tabField: TabAsField = {
...field,
type: 'tab',
}

let childPermissions: SanitizedFieldsPermissions = undefined

if (isNamedTab) {
const shouldContinue = stripUnselectedFields({
field: tabField,
field: { ...field, type: 'tab' },
select,
selectMode,
siblingDoc: data?.[field.name] || {},
Expand All @@ -871,16 +875,34 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
if (!shouldContinue) {
return
}
}

// Tab visibility on the client is keyed by `field.id`, not `path` (like all other fields).
if (field?.id) {
state[field.id] = {
passesCondition,
}

// Flag newly added tab entries so the client accepts them during merge.
// Otherwise, tabs revealed after a hidden ancestor becomes visible would never make it into client form state.
if (!renderAllFields && !previousFormState?.[field.id]) {
state[field.id].addedByServer = true
}
}

if (!passesCondition) {
return
}

let childPermissions: SanitizedFieldsPermissions
let tabSelect: SelectType | undefined

if (isNamedTab) {
if (parentPermissions === true) {
childPermissions = true
} else {
const tabPermissions = parentPermissions?.[field.name]
if (tabPermissions === true) {
childPermissions = true
} else {
childPermissions = tabPermissions?.fields
}
childPermissions = tabPermissions === true ? true : tabPermissions?.fields
}

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

const pathSegments = path ? path.split('.') : []

// If passesCondition is false then this should always result to false
// If the tab has no admin.condition provided then fallback to passesCondition and let that decide the result
let tabPassesCondition = passesCondition

if (passesCondition && typeof field.admin?.condition === 'function') {
tabPassesCondition = field.admin.condition(fullData, data, {
blockData,
operation,
path: pathSegments,
user: req.user,
})
}

if (field?.id) {
state[field.id] = {
passesCondition: tabPassesCondition,
}
}

return iterateFields({
id,
addErrorPathToParent: addErrorPathToParentArg,
Expand All @@ -930,7 +931,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
omitParents,
operation,
parentIndexPath: indexPath,
parentPassesCondition: tabPassesCondition,
parentPassesCondition: passesCondition,
parentPath: path,
parentSchemaPath: schemaPath,
permissions: childPermissions,
Expand All @@ -951,10 +952,6 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
state[path] = {
disableFormData: true,
}

if (passesCondition === false) {
state[path].passesCondition = false
}
}

return iterateFields({
Expand Down
112 changes: 103 additions & 9 deletions test/fields/payload-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export type SupportedTimezones =
export interface Config {
auth: {
users: UserAuthOperations;
'payload-mcp-api-keys': PayloadMcpApiKeyAuthOperations;
};
blocks: {
ConfigBlockTest: ConfigBlockTest;
Expand Down Expand Up @@ -109,6 +110,7 @@ export interface Config {
'uploads-multi-poly': UploadsMultiPoly;
'uploads-restricted': UploadsRestricted;
'ui-fields': UiField;
'payload-mcp-api-keys': PayloadMcpApiKey;
'payload-kv': PayloadKv;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
Expand Down Expand Up @@ -152,6 +154,7 @@ export interface Config {
'uploads-multi-poly': UploadsMultiPolySelect<false> | UploadsMultiPolySelect<true>;
'uploads-restricted': UploadsRestrictedSelect<false> | UploadsRestrictedSelect<true>;
'ui-fields': UiFieldsSelect<false> | UiFieldsSelect<true>;
'payload-mcp-api-keys': PayloadMcpApiKeysSelect<false> | PayloadMcpApiKeysSelect<true>;
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
Expand All @@ -167,7 +170,7 @@ export interface Config {
widgets: {
collections: CollectionsWidget;
};
user: User;
user: User | PayloadMcpApiKey;
jobs: {
tasks: unknown;
workflows: unknown;
Expand All @@ -191,6 +194,24 @@ export interface UserAuthOperations {
password: string;
};
}
export interface PayloadMcpApiKeyAuthOperations {
forgotPassword: {
email: string;
password: string;
};
login: {
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
password: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "ConfigBlockTest".
Expand Down Expand Up @@ -1931,6 +1952,49 @@ export interface UiField {
updatedAt: string;
createdAt: string;
}
/**
* API keys control which collections, resources, tools, and prompts MCP clients can access
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-mcp-api-keys".
*/
export interface PayloadMcpApiKey {
id: string;
/**
* The user that the API key is associated with.
*/
user: string | User;
/**
* A useful label for the API key.
*/
label?: string | null;
/**
* The purpose of the API key.
*/
description?: string | null;
/**
* When checked, this key bypasses Payload access control on every operation it performs. Leave unchecked unless you have a specific reason.
*/
overrideAccess?: boolean | null;
/**
* Access for this API key — uncheck to revoke individual tools.
*/
access?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
enableAPIKey?: boolean | null;
apiKey?: string | null;
apiKeyIndex?: string | null;
collection: 'payload-mcp-api-keys';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv".
Expand Down Expand Up @@ -2098,12 +2162,21 @@ export interface PayloadLockedDocument {
| ({
relationTo: 'ui-fields';
value: string | UiField;
} | null)
| ({
relationTo: 'payload-mcp-api-keys';
value: string | PayloadMcpApiKey;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: string | User;
};
user:
| {
relationTo: 'users';
value: string | User;
}
| {
relationTo: 'payload-mcp-api-keys';
value: string | PayloadMcpApiKey;
};
updatedAt: string;
createdAt: string;
}
Expand All @@ -2113,10 +2186,15 @@ export interface PayloadLockedDocument {
*/
export interface PayloadPreference {
id: string;
user: {
relationTo: 'users';
value: string | User;
};
user:
| {
relationTo: 'users';
value: string | User;
}
| {
relationTo: 'payload-mcp-api-keys';
value: string | PayloadMcpApiKey;
};
key?: string | null;
value?:
| {
Expand Down Expand Up @@ -3724,6 +3802,22 @@ export interface UiFieldsSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-mcp-api-keys_select".
*/
export interface PayloadMcpApiKeysSelect<T extends boolean = true> {
user?: T;
label?: T;
description?: T;
overrideAccess?: T;
access?: T;
updatedAt?: T;
createdAt?: T;
enableAPIKey?: T;
apiKey?: T;
apiKeyIndex?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv_select".
Expand Down
13 changes: 13 additions & 0 deletions test/form-state/collections/Conditions/CustomField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { TextFieldServerComponent } from 'payload'

import { TextField } from '@payloadcms/ui'

export const CustomTextField: TextFieldServerComponent = ({
clientField,
path,
payload,
schemaPath,
}) => {
payload.logger.info('RENDERED CUSTOM SERVER COMPONENT')
return <TextField field={clientField} path={path} schemaPath={schemaPath} />
}
23 changes: 23 additions & 0 deletions test/form-state/collections/Conditions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { CollectionConfig } from 'payload'

export const conditionsSlug = 'conditions'

export const ConditionsCollection: CollectionConfig = {
slug: conditionsSlug,
fields: [
{
name: 'showField',
type: 'checkbox',
},
{
name: 'conditionalCustomField',
type: 'text',
admin: {
condition: (data) => data?.showField === true,
components: {
Field: './collections/Conditions/CustomField.js#CustomTextField',
},
},
},
],
}
3 changes: 2 additions & 1 deletion test/form-state/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ import path from 'path'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { devUser } from '../credentials.js'
import { AutosavePostsCollection } from './collections/Autosave/index.js'
import { ConditionsCollection } from './collections/Conditions/index.js'
import { PostsCollection, postsSlug } from './collections/Posts/index.js'

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

export default buildConfigWithDefaults({
collections: [PostsCollection, AutosavePostsCollection],
collections: [PostsCollection, AutosavePostsCollection, ConditionsCollection],
admin: {
importMap: {
baseDir: path.resolve(dirname),
Expand Down
Loading
Loading