fix: presentational-field types incorrectly exposing hooks (#11514)

### What?

Presentational Fields such as
[Row](https://payloadcms.com/docs/fields/row) are described as only
effecting the admin panel. If they do not impact data, their types
should not include hooks in the fields config.

### Why?

Developers can currently assign hooks to these fields, expecting them to
work, when in reality they are not called.

### How?

Omit `hooks` from `FieldBase`

Fixes #11507

---------

Co-authored-by: German Jablonski <43938777+GermanJablo@users.noreply.github.com>
This commit is contained in:
Dallas Opelt
2025-09-10 11:05:59 -07:00
committed by GitHub
parent e40c82161e
commit 285fc8cf7e
9 changed files with 39 additions and 30 deletions

View File

@@ -95,7 +95,8 @@ Here are the available Presentational Fields:
- [Collapsible](../fields/collapsible) - nests fields within a collapsible component
- [Row](../fields/row) - aligns fields horizontally
- [Tabs (Unnamed)](../fields/tabs) - nests fields within a tabbed layout
- [Tabs (Unnamed)](../fields/tabs) - nests fields within a tabbed layout. It is not presentational if the tab has a name.
- [Group (Unnamed)](../fields/group) - nests fields within a keyed object It is not presentational if the group has a name.
- [UI](../fields/ui) - blank field for custom UI components
### Virtual Fields

View File

@@ -753,7 +753,7 @@ export type NamedGroupField = {
export type UnnamedGroupField = {
interfaceName?: never
localized?: never
} & Omit<GroupBase, 'name' | 'virtual'>
} & Omit<GroupBase, 'hooks' | 'name' | 'virtual'>
export type GroupField = NamedGroupField | UnnamedGroupField
@@ -777,7 +777,7 @@ export type RowField = {
admin?: Omit<Admin, 'description'>
fields: Field[]
type: 'row'
} & Omit<FieldBase, 'admin' | 'label' | 'localized' | 'name' | 'validate' | 'virtual'>
} & Omit<FieldBase, 'admin' | 'hooks' | 'label' | 'localized' | 'name' | 'validate' | 'virtual'>
export type RowFieldClient = {
admin?: Omit<AdminClient, 'description'>
@@ -816,7 +816,7 @@ export type CollapsibleField = {
label: Required<FieldBase['label']>
}
) &
Omit<FieldBase, 'label' | 'localized' | 'name' | 'validate' | 'virtual'>
Omit<FieldBase, 'hooks' | 'label' | 'localized' | 'name' | 'validate' | 'virtual'>
export type CollapsibleFieldClient = {
admin?: {
@@ -863,7 +863,7 @@ export type UnnamedTab = {
| LabelFunction
| string
localized?: never
} & Omit<TabBase, 'name' | 'virtual'>
} & Omit<TabBase, 'hooks' | 'name' | 'virtual'>
export type Tab = NamedTab | UnnamedTab
export type TabsField = {

View File

@@ -73,7 +73,7 @@ export const promise = async ({
if (fieldAffectsData(field)) {
// Execute hooks
if (field.hooks?.afterChange) {
if ('hooks' in field && field.hooks?.afterChange) {
for (const hook of field.hooks.afterChange) {
const hookedValue = await hook({
blockData,
@@ -88,16 +88,16 @@ export const promise = async ({
path: pathSegments,
previousDoc,
previousSiblingDoc,
previousValue: previousDoc?.[field.name!],
previousValue: previousDoc?.[field.name],
req,
schemaPath: schemaPathSegments,
siblingData,
siblingFields: siblingFields!,
value: siblingDoc?.[field.name!],
value: siblingDoc?.[field.name],
})
if (hookedValue !== undefined) {
siblingDoc[field.name!] = hookedValue
siblingDoc[field.name] = hookedValue
}
}
}

View File

@@ -238,15 +238,15 @@ export const promise = async ({
if (fieldAffectsDataResult) {
// Execute hooks
if (triggerHooks && field.hooks?.afterRead) {
if (triggerHooks && 'hooks' in field && field.hooks?.afterRead) {
for (const hook of field.hooks.afterRead) {
const shouldRunHookOnAllLocales =
fieldShouldBeLocalized({ field, parentIsLocalized: parentIsLocalized! }) &&
(locale === 'all' || !flattenLocales) &&
typeof siblingDoc[field.name!] === 'object'
typeof siblingDoc[field.name] === 'object'
if (shouldRunHookOnAllLocales) {
const localesAndValues = Object.entries(siblingDoc[field.name!])
const localesAndValues = Object.entries(siblingDoc[field.name])
await Promise.all(
localesAndValues.map(async ([localeKey, value]) => {
const hookedValue = await hook({
@@ -274,7 +274,7 @@ export const promise = async ({
})
if (hookedValue !== undefined) {
siblingDoc[field.name!][localeKey] = hookedValue
siblingDoc[field.name][localeKey] = hookedValue
}
}),
)
@@ -300,11 +300,11 @@ export const promise = async ({
showHiddenFields,
siblingData: siblingDoc,
siblingFields: siblingFields!,
value: siblingDoc[field.name!],
value: siblingDoc[field.name],
})
if (hookedValue !== undefined) {
siblingDoc[field.name!] = hookedValue
siblingDoc[field.name] = hookedValue
}
}
}

View File

@@ -129,7 +129,7 @@ export const promise = async ({
}
// Execute hooks
if (field.hooks?.beforeChange) {
if ('hooks' in field && field.hooks?.beforeChange) {
for (const hook of field.hooks.beforeChange) {
const hookedValue = await hook({
blockData,
@@ -143,17 +143,17 @@ export const promise = async ({
originalDoc: doc,
path: pathSegments,
previousSiblingDoc: siblingDoc,
previousValue: siblingDoc[field.name!],
previousValue: siblingDoc[field.name],
req,
schemaPath: schemaPathSegments,
siblingData,
siblingDocWithLocales,
siblingFields: siblingFields!,
value: siblingData[field.name!],
value: siblingData[field.name],
})
if (hookedValue !== undefined) {
siblingData[field.name!] = hookedValue
siblingData[field.name] = hookedValue
}
}
}

View File

@@ -65,7 +65,7 @@ export const promise = async <T>({
// Run field beforeDuplicate hooks.
// These hooks are responsible for resetting the `id` field values of array and block rows. See `baseIDField`.
if (Array.isArray(field.hooks?.beforeDuplicate)) {
if (Array.isArray('hooks' in field && field.hooks?.beforeDuplicate)) {
if (fieldIsLocalized) {
const localeData: JsonObject = {}
@@ -90,8 +90,10 @@ export const promise = async <T>({
}
let hookResult
for (const hook of field.hooks.beforeDuplicate) {
hookResult = await hook(beforeDuplicateArgs)
if ('hooks' in field && field.hooks?.beforeDuplicate) {
for (const hook of field.hooks.beforeDuplicate) {
hookResult = await hook(beforeDuplicateArgs)
}
}
if (typeof hookResult !== 'undefined') {
@@ -121,8 +123,10 @@ export const promise = async <T>({
}
let hookResult
for (const hook of field.hooks.beforeDuplicate) {
hookResult = await hook(beforeDuplicateArgs)
if ('hooks' in field && field.hooks?.beforeDuplicate) {
for (const hook of field.hooks.beforeDuplicate) {
hookResult = await hook(beforeDuplicateArgs)
}
}
if (typeof hookResult !== 'undefined') {

View File

@@ -284,7 +284,7 @@ export const promise = async <T>({
}
// Execute hooks
if (field.hooks?.beforeValidate) {
if ('hooks' in field && field.hooks?.beforeValidate) {
for (const hook of field.hooks.beforeValidate) {
const hookedValue = await hook({
blockData,
@@ -299,19 +299,19 @@ export const promise = async <T>({
overrideAccess,
path: pathSegments,
previousSiblingDoc: siblingDoc,
previousValue: siblingDoc[field.name!],
previousValue: siblingDoc[field.name],
req,
schemaPath: schemaPathSegments,
siblingData,
siblingFields: siblingFields!,
value:
typeof siblingData[field.name!] === 'undefined'
typeof siblingData[field.name] === 'undefined'
? fallbackResult.value
: siblingData[field.name!],
: siblingData[field.name],
})
if (hookedValue !== undefined) {
siblingData[field.name!] = hookedValue
siblingData[field.name] = hookedValue
}
}
}

View File

@@ -13,6 +13,7 @@ export const setDefaultBeforeDuplicate = (
) => {
if (
(('required' in field && field.required) || field.unique) &&
'hooks' in field &&
(!field.hooks?.beforeDuplicate ||
(Array.isArray(field.hooks.beforeDuplicate) && field.hooks.beforeDuplicate.length === 0))
) {

View File

@@ -119,7 +119,10 @@ export const getFields = ({
generateFileURL,
size,
}),
...(existingSizeURLField?.hooks?.afterRead || []),
...((typeof existingSizeURLField === 'object' &&
'hooks' in existingSizeURLField &&
existingSizeURLField?.hooks?.afterRead) ||
[]),
],
},
},